diff --git a/article_server.py b/article_server.py
index 27621a5..e7433df 100644
--- a/article_server.py
+++ b/article_server.py
@@ -225,7 +225,7 @@ def build_index(articles: list[dict]) -> str:
ready_dot(image_ok, "Bild")
)
- items_html += f"""
\n"""
items_html += "\n"
@@ -334,6 +338,14 @@ def build_index(articles: list[dict]) -> str:
.upload-result {{ margin-top: 0.8rem; font-size: 0.82rem; font-family: monospace; word-break: break-all; padding: 0.5rem; background: #f0fdf4; border-radius: 6px; display: none; }}
.upload-result.show {{ display: block; }}
+ .row-upload-btn {{ display: inline-flex; align-items: center; justify-content: center; width: 32px; height: 32px; border-radius: 8px; cursor: pointer; color: var(--accent); background: var(--accent-ghost); border: 1px solid transparent; transition: all 0.15s; flex-shrink: 0; margin-left: 0.25rem; }}
+ .row-upload-btn:hover {{ background: var(--accent); color: #fff; border-color: var(--accent); }}
+ .row-upload-btn:active {{ transform: scale(0.93); }}
+ .row-upload-btn.uploading {{ background: var(--success); color: #fff; pointer-events: none; animation: pulse 0.8s infinite; }}
+ @keyframes pulse {{ 0%,100%{{opacity:1}} 50%{{opacity:0.5}} }}
+
+ .row-file-input {{ display: none; }}
+
.toast {{ position: fixed; bottom: 2rem; right: 2rem; background: var(--success); color: #fff; padding: 0.8rem 1.6rem; border-radius: 10px; font-weight: 500; font-family: var(--font); box-shadow: 0 6px 24px rgba(16,185,129,0.3); opacity: 0; transform: translateY(16px); transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1); pointer-events: none; z-index: 1000; }}
.toast.show {{ opacity: 1; transform: translateY(0); }}
.toast.error {{ background: var(--danger); box-shadow: 0 6px 24px rgba(192,57,43,0.3); }}
@@ -468,6 +480,46 @@ def build_index(articles: list[dict]) -> str:
}});
}}
+ async function uploadForRow(input, articleFile) {{
+ const file = input.files[0];
+ if (!file) return;
+ const btn = input.closest('.row-upload-btn');
+ if (btn) btn.classList.add('uploading');
+
+ const formData = new FormData();
+ formData.append('file', file);
+ formData.append('article', articleFile);
+
+ try {{
+ const resp = await fetch('/upload?key={ACCESS_TOKEN}', {{ method: 'POST', body: formData }});
+ const data = await resp.json();
+ if (btn) btn.classList.remove('uploading');
+ if (data.ok) {{
+ showToast('β
' + file.name + ' β ' + articleFile.split('_2026')[0].replace(/_/g,' ').replace('artikel ',''), false);
+ // Update image badge (bump count visually)
+ const row = input.closest('.article-row');
+ if (row) {{
+ const badge = row.querySelector('.img-badge');
+ if (badge) {{
+ const match = badge.textContent.match(/\\d+/);
+ const current = match ? parseInt(match[0]) : 0;
+ badge.textContent = 'πΌ' + (current + 1);
+ }} else {{
+ const info = row.querySelector('.article-info');
+ if (info) info.insertAdjacentHTML('afterend', 'πΌ1');
+ }}
+ }}
+ // Clear the file input so the same file can be re-uploaded
+ input.value = '';
+ }} else {{
+ showToast('β ' + (data.error || 'Fehler'), true);
+ }}
+ }} catch(e) {{
+ if (btn) btn.classList.remove('uploading');
+ showToast('β ' + e.message, true);
+ }}
+ }}
+
const uploadZone = document.getElementById('uploadZone');
uploadZone.addEventListener('dragover', e => {{ e.preventDefault(); uploadZone.classList.add('dragover'); }});
uploadZone.addEventListener('dragleave', () => uploadZone.classList.remove('dragover'));
@@ -797,7 +849,7 @@ h1{color:#c0392b;}p{color:#888;}
filepath = os.path.join(month_dir, unique_name)
with open(filepath, "wb") as f:
- f.write(data)
+ f.write(file_data)
# ββ Article-Image Association ββββββββββββββββββββββββββββββββββββββ
association_msg = ""
diff --git a/article_server.py.bak b/article_server.py.bak
new file mode 100644
index 0000000..27621a5
--- /dev/null
+++ b/article_server.py.bak
@@ -0,0 +1,1146 @@
+#!/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'\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}
+
+
{a['title']}
+ {meta_preview}
+
+
{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
+
+
+
+
+
+
+ π 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 verweigertZugriff 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.
+
+ 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
+ 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' or )
+ body_match = re.search(r']*>(.*?)', html_content, re.DOTALL)
+ if not body_match:
+ # Fallback: use everything between and