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'(?:|)(.*?)(?: