#!/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.""" import os import re import glob import json import base64 import hashlib import socket import urllib.parse import io import email.parser import tempfile 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_" # ── 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 } 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', ] # ── 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*\w+\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{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.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 == "/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() 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 _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 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 H1 + Meta-Zeile aus dem Body ───────────────── # Daniel 19.06.: Titel und Lesezeit-Zeile dürfen NICHT im # Fließtext landen — sie werden von Joomla separat gerendert. 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) # Extract Joomla ID if present (for updates) jid_match = re.search(r'<meta\s+name="joomla-id"\s+content="(\d+)"', 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 aus Pool holen & einbauen ───────── # NUR bei LIVE-Artikeln (state=1)! Korrektur verbraucht keine Zählmarke. 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) 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) 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, } article_payload = { "title": title, "alias": alias, "articletext": article_body.strip(), "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 ────────────────────────────── if fname in img_map and img_map[fname]: image_path = img_map[fname][0] # First image if os.path.exists(image_path): try: 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") 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)}, 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}" article_payload["images"] = { "image_intro": j_img_path, "image_intro_alt": title, "image_fulltext": j_img_path, "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) 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 and not joomla_id: # Inject <meta name="joomla-id"> into the HTML file 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="(\d+)"', 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, "published": published, "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 }) 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")) def main(): import sys os.chdir(WORKSPACE) server = ThreadingArticleServer(("0.0.0.0", PORT), ArticleHandler) has_token = "JA" if JOOMLA_TOKEN else "NEIN (Veröffentlichung deaktiviert)" has_vgwort = VG_WORT_COUNTER_ID or "NICHT KONFIGURIERT" print(f"Article server running on http://0.0.0.0:{PORT}") print(f" Joomla-Token: {has_token}") print(f" VG-Wort-ID: {has_vgwort}") sys.stdout.flush() try: server.serve_forever() except KeyboardInterrupt: print("\nShutting down.") server.server_close() if __name__ == "__main__": main()