diff --git a/article_server.py.bak b/article_server.py.bak
deleted file mode 100644
index 27621a5..0000000
--- a/article_server.py.bak
+++ /dev/null
@@ -1,1146 +0,0 @@
-#!/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