diff --git a/article_server.py b/article_server.py index 101d4bc..6900192 100644 --- a/article_server.py +++ b/article_server.py @@ -1,7 +1,8 @@ #!/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.""" + and automatic VG-Wort tracking pixel injection. +v3: Added NanoBanana Image Generator (/nanobanana).""" import os import re @@ -11,9 +12,12 @@ 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 @@ -62,6 +66,87 @@ MONTHS = { "September": 9, "Oktober": 10, "November": 11, "Dezember": 12 } +# ── NanoBanana API Configuration ────────────────────────────────────────── +NANOBANANA_API_URL = "https://api.nanobananaapi.ai" +NANOBANANA_UPLOAD_DIR = "/tmp/nanobanana_uploads" +os.makedirs(NANOBANANA_UPLOAD_DIR, exist_ok=True) + +def _get_nanobanana_key() -> str: + """NanoBanana API-Key aus bsn-secrets lesen.""" + try: + r = subprocess.run( + ["/home/hermes/bin/bsn-secrets", "get", "bsn/nanobanana-key"], + capture_output=True, text=True, timeout=5 + ) + return r.stdout.strip() + except Exception: + return os.environ.get("NANOBANANA_API_KEY", "") + +def _nanobanana_generate(prompt: str, image_urls: list, aspect_ratio: str, resolution: str) -> dict: + """NanoBanana generate-2 API aufrufen.""" + key = _get_nanobanana_key() + if not key: + return {"error": "Kein API-Key"} + + data = json.dumps({ + "prompt": prompt, + "imageUrls": image_urls if image_urls else [], + "aspectRatio": aspect_ratio, + "resolution": resolution, + "outputFormat": "jpg", + }).encode() + + req = urllib.request.Request( + f"{NANOBANANA_API_URL}/api/v1/nanobanana/generate-2", + data=data, method="POST" + ) + req.add_header("Authorization", "Bearer " + key) + req.add_header("Content-Type", "application/json") + req.add_header("User-Agent", "Mozilla/5.0") # Cloudflare-Workaround + + try: + with urllib.request.urlopen(req, timeout=30) as resp: + return json.loads(resp.read()) + except urllib.error.HTTPError as e: + return {"error": f"HTTP {e.code}: {e.read().decode()[:300]}"} + +def _nanobanana_status(task_id: str) -> dict: + """NanoBanana Task-Status abrufen.""" + key = _get_nanobanana_key() + if not key: + return {"error": "Kein API-Key"} + + req = urllib.request.Request( + f"{NANOBANANA_API_URL}/api/v1/nanobanana/record-info?taskId={task_id}" + ) + req.add_header("Authorization", "Bearer " + key) + req.add_header("User-Agent", "Mozilla/5.0") + + try: + with urllib.request.urlopen(req, timeout=15) as resp: + return json.loads(resp.read()) + except urllib.error.HTTPError as e: + return {"error": f"HTTP {e.code}"} + +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', @@ -127,7 +212,7 @@ def extract_meta(filepath: str) -> dict | None: return None title = title_match.group(1) - date_match = re.search(r'

(\d{1,2}\.\s*\w+\s*\d{4})', content) + 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) @@ -152,7 +237,7 @@ def extract_meta(filepath: str) -> dict | None: meta_tags = tag_match.group(1) joomla_id = "" - jid_match = re.search(r' 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 == "/nanobanana/status": + self._handle_nanobanana_status() elif path.startswith("/media/"): rel = path[len("/"):] filepath = os.path.normpath(os.path.join(WORKSPACE, rel)) @@ -857,6 +952,22 @@ h1{color:#c0392b;}p{color:#888;} if not self._check_token(): return self._handle_assign_image() + elif path == "/nanobanana/generate": + if not self._check_token(): + return + self._handle_nanobanana_generate() + elif path == "/nanobanana/status": + if not self._check_token(): + return + self._handle_nanobanana_status() + 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 == "/delete": if not self._check_auth(): return @@ -869,6 +980,167 @@ h1{color:#c0392b;}p{color:#888;} self.send_response(404) self.end_headers() + # ═══════════════════════════════════════════════════════════════════ + # NanoBanana Handler + # ═══════════════════════════════════════════════════════════════════ + + def _handle_nanobanana_generate(self): + """NanoBanana Bildgenerierung starten.""" + 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 = "" + image_urls = [] + aspect_ratio = "16:9" + resolution = "2K" + + 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:] + content = content.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: + # Bild als Data-URL für die NanoBanana API + import base64 as b64 + b64_data = b64.b64encode(content).decode() + ct = "image/jpeg" + if b"Content-Type" in part: + ct_line = [l for l in headers_raw.split("\r\n") if "Content-Type" in l] + if ct_line: + ct = ct_line[0].split(":")[-1].strip() + image_urls.append(f"data:{ct};base64,{b64_data}") + + if not prompt: + self._json_reply(400, {"ok": False, "error": "Prompt fehlt"}) + return + + result = _nanobanana_generate(prompt, image_urls, aspect_ratio, resolution) + if "error" in result: + self._json_reply(500, {"ok": False, "error": result["error"]}) + return + + task_id = result.get("data", {}).get("taskId", "") + self._json_reply(200, {"ok": True, "taskId": task_id}) + + def _handle_nanobanana_status(self): + """NanoBanana Task-Status abrufen.""" + parsed = urllib.parse.urlparse(self.path) + params = urllib.parse.parse_qs(parsed.query) + task_id = params.get("taskId", [""])[0] + + if not task_id: + self._json_reply(400, {"ok": False, "error": "taskId fehlt"}) + return + + result = _nanobanana_status(task_id) + if "error" in result: + self._json_reply(500, {"ok": False, "error": result["error"]}) + return + + data = result.get("data", {}) + self._json_reply(200, { + "ok": True, + "successFlag": data.get("successFlag"), + "resultImageUrl": data.get("response", {}).get("resultImageUrl"), + "completeTime": data.get("completeTime"), + }) + + 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 + try: + 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", "") @@ -1041,6 +1313,7 @@ h1{color:#c0392b;}p{color:#888;} return published = 0 + published_ids = [] images_deleted_total = 0 errors = [] img_map = get_image_map() @@ -1112,14 +1385,25 @@ h1{color:#c0392b;}p{color:#888;} body_match = re.search(r'(?:|)(.*?)(?:]*>.*?\s*', '', article_body, flags=re.DOTALL) article_body = re.sub(r']*>.*?

\s*', '', article_body, flags=re.DOTALL) + article_body = re.sub(r']*>.*?

\s*', '', article_body, flags=re.DOTALL) + # ── Umbruch vor Überschriften (Daniel 19.06.: bessere Lesbarkeit) ── + article_body = re.sub(r'()\\s*(\\n\\3', article_body) + # ── Remove
after read-more (muss NACH Umbruch-Insertion laufen!) ── + article_body = re.sub( + r'()\s*\s*', + r'\1\n', + article_body, flags=re.IGNORECASE + ) + # ── Strip trailing whitespace/newlines before closing ── + article_body = re.sub(r'\s*', '', article_body) # Extract Joomla ID if present (for updates) - jid_match = re.search(r' # Generate alias from title alias = re.sub(r'[^a-z0-9]+', '-', title.lower().strip())[:100] - # ── VG-Wort Zählmarke aus Pool holen & einbauen ───────── - # NUR bei LIVE-Artikeln (state=1)! Korrektur verbraucht keine Zählmarke. + # ── 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 = "" - if state == 1: - 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) + 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] Pool-Script fehlgeschlagen: {result.stderr}", flush=True) - except Exception as e: - print(f"[VG-Wort] Ausnahme: {e}", flush=True) - else: - print(f"[VG-Wort] Übersprungen — Artikel geht auf Korrektur (state=0), " - f"Zählmarke erst bei Live-Gang", flush=True) + 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 '' in article_body: @@ -1198,10 +1479,20 @@ h1{color:#c0392b;}p{color:#888;} "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'', article_body, re.IGNORECASE) + if sr_match: + split_idx = sr_match.end() + introtext = article_body[:split_idx].strip() + fulltext = article_body[split_idx:].strip() + article_payload = { "title": title, "alias": alias, - "articletext": article_body.strip(), + "introtext": introtext, + "fulltext": fulltext, "catid": catid, "language": "*", "state": state, @@ -1214,44 +1505,60 @@ h1{color:#c0392b;}p{color:#888;} 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 ────────────────────────────── + # ── 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) - ext = os.path.splitext(img_filename)[1].lower() - mime_map = {".jpg": "image/jpeg", ".jpeg": "image/jpeg", - ".png": "image/png", ".webp": "image/webp", - ".gif": "image/gif"} - mime_type = mime_map.get(ext, "image/jpeg") + 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: - from datetime import datetime as dt - year = dt.now().strftime("%Y") - img_resp = requests.post( - "https://www.brettspiel-news.de/api/index.php/v1/media", - headers={"X-Joomla-Token": JOOMLA_TOKEN}, - data={"path": f"Spiele/{year}/"}, - files={"file": (img_filename, img_f, mime_type)}, + 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): - img_data = img_resp.json() - # Joomla 4 returns the relative path - j_img_path = None - if "data" in img_data and "attributes" in img_data["data"]: - j_img_path = img_data["data"]["attributes"].get("path", "") - if not j_img_path: - j_img_path = f"Spiele/{year}/{img_filename}" + 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 + image_intro_value = f"images/{j_img_path}#joomlaImage://local-images/{j_img_path}" article_payload["images"] = { - "image_intro": j_img_path, + "image_intro": image_intro_value, "image_intro_alt": title, - "image_fulltext": j_img_path, + "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}", flush=True) + 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: @@ -1284,8 +1591,11 @@ h1{color:#c0392b;}p{color:#888;} # Some responses nest the id differently pass - if new_jid and not joomla_id: - # Inject into the HTML file + if new_jid: + published_ids.append(new_jid) + + if new_jid: + # Immer Joomla-ID in die HTML-Datei schreiben (auch bei Updates) if '' html_content = re.sub( @@ -1296,7 +1606,7 @@ h1{color:#c0392b;}p{color:#888;} ) else: html_content = re.sub( - r' self._json_reply(200 if published > 0 else 500, { "ok": published > 0, "published": published, + "published_ids": published_ids, "live_mode": allow_live, "images_deleted": images_deleted_total, "errors": errors, @@ -1339,6 +1650,578 @@ h1{color:#c0392b;}p{color:#888;} self.wfile.write(json.dumps(data).encode("utf-8")) +# ═══════════════════════════════════════════════════════════════════════════ +# NanoBanana Editor HTML +# ═══════════════════════════════════════════════════════════════════════════ + +NANOBANANA_HTML = """ + + + + +BSN Bild-Generator — NanoBanana + + + +
+ 🎨 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 +
+ + +
+
+ +
+ + +
+
+
+ +
+ +
+ + + +""" + + def main(): import sys os.chdir(WORKSPACE)