commit 2f356ddfb8478f7524a4ea21236be100d8f6c497 Author: Hermes Agent Date: Thu Jun 18 18:33:25 2026 +0200 Initial commit: article-server mit Publish-Workflow - Publish-Gate entfernt (Korrektur ist Redaktion-only) - Default catid=61 (Korrektur), state=0, access=6 - Öffentliche Rubriken auto-featured für 3 Tage - featured_up=jetzt, featured_down=+3 Tage - per meta name='featured-days' content='N' anpassbar diff --git a/article_server.py b/article_server.py new file mode 100644 index 0000000..2296e08 --- /dev/null +++ b/article_server.py @@ -0,0 +1,1083 @@ +#!/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 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 für Artikel hochladen

+

1. Wähle den Artikel-Dateinamen aus der Liste (z.B. artikel_spirit_island_retro_2017_2026-06-13.html)

+

2. Wähle die Bilddatei (.jpg, .png, .webp)

+

Das Bild wird dem Artikel zugeordnet und nach Veröffentlichung automatisch gelöscht.

+
+ + +
+
+
+ {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 == "/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(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_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.""" + 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", []) + 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() + + # 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 + + # 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 + + # Extract category from meta or default to "Korrektur" (catid=61) + catid = 61 # Default: Korrektur (Redaktion-only, nicht öffentlich) + 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) + 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 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] + + # ── Inject VG-Wort pixel ─────────────────────────────────── + if VG_WORT_PIXEL_HTML and VG_WORT_COUNTER_ID: + # Insert pixel right before closing </article> or at the end of body + if '</article>' in article_body: + article_body = article_body.replace('</article>', f'{VG_WORT_PIXEL_HTML}\n</article>') + else: + article_body = article_body.rstrip() + f'\n{VG_WORT_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, + } + 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) + + # ── Auto-delete associated images ─────────────────────── + if fname in img_map: + for img_path in img_map[fname]: + if os.path.exists(img_path): + try: + os.remove(img_path) + images_deleted_total += 1 + except OSError as e: + errors.append(f"{fname}: Bild {img_path} konnte nicht gelöscht werden: {e}") + 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, + "images_deleted": images_deleted_total, + "errors": errors, + "vg_wort_enabled": bool(VG_WORT_COUNTER_ID), + }) + + 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()