Files
BSN-Chatsystem/article_server.py
T
Hermes Agent 6e20684af4 feat: Verwaiste-Bilder-Scanner — Orphans automatisch finden + per Dropdown zuordnen
- find_orphan_images(): scannt Workspace nach Bildern ohne article_images.json-Eintrag
- Gelbe Orphan-Box oben auf der Seite: alle verwaisten Bilder mit Artikel-Dropdown
- assignOrphan() JS: Dropdown-Auswahl → fetch /assign-image → Toast + Reload
- _handle_assign_image(): Backend validiert Bildpfad + Artikel + speichert Mapping
- Test-JPEGs aus media/ bereinigt, article_images.json sauber initialisiert
2026-06-19 00:12:59 +02:00

1311 lines
61 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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'<img src="https://vg08.met.vgwort.de/na/{VG_WORT_COUNTER_ID}" '
f'width="1" height="1" alt="">'
) 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'<title>([^<]+)</title>', content)
if not title_match:
return None
title = title_match.group(1)
date_match = re.search(r'<p class="meta">(\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'<meta\s+name="description"\s+content="([^"]*)"', content, re.IGNORECASE)
if desc_match:
meta_desc = desc_match.group(1)
meta_tags = ""
tag_match = re.search(r'<meta\s+name="keywords"\s+content="([^"]*)"', content, re.IGNORECASE)
if tag_match:
meta_tags = tag_match.group(1)
joomla_id = ""
jid_match = re.search(r'<meta\s+name="joomla-id"\s+content="(\d+)"', content, re.IGNORECASE)
if jid_match:
joomla_id = jid_match.group(1)
return {
"filename": os.path.basename(filepath),
"title": title,
"date_sort": sort_key,
"date_display": display_date,
"mtime": os.path.getmtime(filepath),
"meta_desc": meta_desc,
"meta_tags": meta_tags,
"joomla_id": joomla_id,
}
def get_articles() -> 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()
# ── Orphaned images section ──
orphan_imgs = find_orphan_images()
orphan_section = ""
if orphan_imgs:
article_opts = "\\n".join(
f'<option value="{a["filename"]}">{a["title"][:80]}</option>'
for a in articles
)
orphan_rows = "\\n".join(
f'''<div class="orphan-row" data-img="{p}">
<span class="orphan-preview">🖼️ {os.path.basename(p)}</span>
<select class="orphan-select" onchange="assignOrphan(this, '{p}')">
<option value="">→ Artikel zuordnen…</option>
{article_opts}
</select>
</div>'''
for p in orphan_imgs[:20] # max 20 to keep page fast
)
orphan_section = f'''<details class="orphan-box" open>
<summary>🖼️ {len(orphan_imgs)} verwaiste Bilder — keinem Artikel zugeordnet</summary>
<div class="orphan-grid">{orphan_rows}</div>
</details>'''
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'<div class="date-group">\n'
items_html += f'<div class="date-header">{nice_date}<span class="date-count">{len(day_articles)}</span></div>\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'<span class="tags">🏷 {meta_tags[:60]}{"" if len(meta_tags) > 60 else ""}</span>')
meta_preview = '<div class="meta-preview">' + " · ".join(meta_parts) + '</div>'
# 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'<span class="img-badge" title="{img_count} Bild(er) zugeordnet">🖼{img_count}</span>'
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'<span class="ready-dot {cls}" title="{title}: {"" if ok else "✗ fehlt"}">{symbol}</span>'
readiness = (
ready_dot(meta_desc_ok, "Meta-Description") +
ready_dot(meta_tags_ok, "Keywords") +
ready_dot(image_ok, "Bild")
)
items_html += f"""<div class="article-row" data-ready="{str(all_ready).lower()}" data-filename="{a['filename']}">
<label class="checkbox-label">
<input type="checkbox" class="article-check" value="{a['filename']}">
<span class="checkmark"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg></span>
</label>
<span class="article-id {id_class}">{id_str}</span>
<div class="article-info">
<a href="{a['filename']}?key={ACCESS_TOKEN}" class="article-link" target="_blank">{a['title']}</a>
{meta_preview}
</div>
<span class="readiness">{readiness}</span>
{image_badge}
<label class="row-upload-btn" title="Bild für diesen Artikel hochladen">
<input type="file" accept="image/*,.jpg,.jpeg,.png,.webp,.gif" class="row-file-input" onchange="uploadForRow(this, '{a['filename']}')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
</label>
</div>\n"""
items_html += "</div>\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"""<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Artikel-Übersicht · brettspiel-news.de</title>
<style>
:root {{ --accent: #20228a; --accent-light: #3b3da8; --accent-ghost: rgba(32,34,138,0.06); --accent-ghost-hover: rgba(32,34,138,0.10); --danger: #c0392b; --danger-hover: #a93226; --success: #10b981; --success-hover: #059669; --bg: #f8f9fb; --surface: #ffffff; --text: #1a1a2e; --text-muted: #6b7280; --border: #e5e7eb; --radius: 10px; --shadow-sm: 0 1px 3px rgba(0,0,0,0.04); --shadow: 0 2px 12px rgba(0,0,0,0.06); --font: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; }}
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
body {{ font-family: var(--font); background: var(--bg); color: var(--text); line-height: 1.5; -webkit-font-smoothing: antialiased; }}
.container {{ max-width: 900px; margin: 0 auto; padding: 0 1.5rem 2rem; }}
header {{ background: linear-gradient(135deg, var(--accent) 0%, var(--accent-light) 100%); padding: 2rem 0; margin-bottom: 2rem; box-shadow: 0 4px 24px rgba(32,34,138,0.15); }}
header .header-inner {{ max-width: 900px; margin: 0 auto; padding: 0 1.5rem; display: flex; align-items: baseline; justify-content: space-between; flex-wrap: wrap; gap: 0.5rem; }}
header h1 {{ font-size: 1.4rem; font-weight: 700; color: #fff; letter-spacing: -0.02em; }}
header .subtitle {{ color: rgba(255,255,255,0.8); font-size: 0.88rem; font-weight: 400; }}
header .subtitle a {{ color: rgba(255,255,255,0.95); text-decoration: underline; text-underline-offset: 3px; }}
header .subtitle a:hover {{ color: #fff; }}
.toolbar {{ display: flex; gap: 0.6rem; align-items: center; margin-bottom: 1.8rem; flex-wrap: wrap; }}
.btn {{ padding: 0.45rem 1rem; border: none; border-radius: 7px; font-size: 0.84rem; cursor: pointer; font-weight: 500; font-family: var(--font); transition: all 0.15s ease; white-space: nowrap; }}
.btn-select {{ background: var(--surface); color: var(--text); border: 1px solid var(--border); box-shadow: var(--shadow-sm); }}
.btn-select:hover {{ background: var(--accent-ghost-hover); border-color: var(--accent); color: var(--accent); }}
.btn-upload {{ background: var(--accent); color: #fff; box-shadow: 0 2px 8px rgba(32,34,138,0.25); }}
.btn-upload:hover {{ background: var(--accent-light); box-shadow: 0 4px 14px rgba(32,34,138,0.35); }}
.btn-delete {{ background: var(--danger); color: #fff; box-shadow: 0 2px 8px rgba(192,57,43,0.25); }}
.btn-delete:hover {{ background: var(--danger-hover); box-shadow: 0 4px 14px rgba(192,57,43,0.35); }}
.btn-delete:disabled {{ background: #e5e7eb; color: #9ca3af; cursor: not-allowed; box-shadow: none; }}
.btn-publish {{ background: var(--success); color: #fff; box-shadow: 0 2px 8px rgba(16,185,129,0.25); }}
.btn-publish:hover {{ background: var(--success-hover); box-shadow: 0 4px 14px rgba(16,185,129,0.35); }}
.btn-publish:disabled {{ background: #e5e7eb; color: #9ca3af; cursor: not-allowed; box-shadow: none; }}
.live-toggle-label {{ display: flex; align-items: center; gap: 0.3rem; cursor: pointer; font-size: 0.82rem; font-weight: 600; padding: 0.35rem 0.6rem; border-radius: 7px; background: var(--surface); border: 2px solid var(--border); transition: all 0.15s; user-select: none; }}
.live-toggle-label:hover {{ border-color: var(--accent); }}
.live-toggle-label:has(input:checked) {{ background: #fef2f2; border-color: #dc3545; color: #dc3545; }}
.live-toggle-label input {{ accent-color: #dc3545; }}
.upload-label {{ display: inline-flex; }}
.token-indicator {{ font-size: 0.7rem; margin-left: 0.6rem; opacity: 0.8; }}
.date-group {{ margin-bottom: 1.6rem; background: var(--surface); border-radius: var(--radius); box-shadow: var(--shadow); overflow: hidden; }}
.date-header {{ font-size: 0.82rem; font-weight: 600; color: var(--accent); text-transform: uppercase; letter-spacing: 0.06em; padding: 0.7rem 1rem; background: var(--accent-ghost); border-bottom: 1px solid rgba(32,34,138,0.08); display: flex; align-items: center; justify-content: space-between; }}
.date-count {{ background: var(--accent); color: #fff; font-size: 0.7rem; padding: 0.15rem 0.55rem; border-radius: 20px; font-weight: 600; letter-spacing: 0; }}
.article-row {{ display: flex; align-items: flex-start; gap: 0.6rem; padding: 0.6rem 1rem; transition: background 0.12s; border-bottom: 1px solid rgba(0,0,0,0.03); }}
.article-row:last-child {{ border-bottom: none; }}
.article-row:hover {{ background: var(--accent-ghost); }}
.article-row.selected {{ background: var(--accent-ghost-hover); }}
.article-row.selected .article-link {{ color: var(--accent); }}
.checkbox-label {{ position: relative; display: flex; align-items: flex-start; cursor: pointer; flex-shrink: 0; padding-top: 2px; }}
.checkbox-label input {{ display: none; }}
.checkmark {{ width: 20px; height: 20px; border: 2px solid #d1d5db; border-radius: 5px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; transition: all 0.15s; color: transparent; }}
.checkmark svg {{ width: 13px; height: 13px; display: none; }}
.checkbox-label input:checked + .checkmark {{ background: var(--accent); border-color: var(--accent); color: #fff; }}
.checkbox-label input:checked + .checkmark svg {{ display: block; }}
.article-row:hover .checkmark {{ border-color: var(--accent); }}
.article-id {{ font-size: 0.75rem; font-weight: 600; padding: 2px 7px; border-radius: 4px; flex-shrink: 0; margin-top: 1px; min-width: 32px; text-align: center; }}
.id-joomla {{ background: #dbeafe; color: #1e40af; }}
.id-local {{ background: #f3f4f6; color: #6b7280; }}
.article-info {{ flex: 1; min-width: 0; }}
.article-link {{ color: var(--text); text-decoration: none; font-size: 0.93rem; line-height: 1.4; display: block; }}
.article-link:hover {{ color: var(--accent); }}
.meta-preview {{ font-size: 0.78rem; color: var(--text-muted); margin-top: 0.2rem; line-height: 1.35; }}
.meta-preview .tags {{ color: var(--accent); font-size: 0.75rem; }}
.img-badge {{ font-size: 0.7rem; background: #dbeafe; color: #1e40af; padding: 1px 6px; border-radius: 10px; margin-left: 0.5rem; white-space: nowrap; flex-shrink: 0; }}
.readiness {{ display: flex; gap: 3px; align-items: center; flex-shrink: 0; margin: 0 0.3rem; }}
.ready-dot {{ font-size: 0.62rem; line-height: 1; }}
.ready-ok {{ color: #10b981; }}
.ready-missing {{ color: #d1d5db; }}
.counter {{ color: var(--text-muted); font-size: 0.84rem; margin-left: auto; font-variant-numeric: tabular-nums; }}
.upload-zone {{ display: none; margin-bottom: 1.5rem; padding: 2rem; border: 2px dashed var(--border); border-radius: var(--radius); text-align: center; background: var(--surface); transition: all 0.2s; }}
.upload-zone.active {{ display: block; }}
.upload-zone.dragover {{ border-color: var(--accent); background: var(--accent-ghost); }}
.upload-zone h3 {{ font-size: 1rem; color: var(--text); margin-bottom: 0.5rem; }}
.upload-zone p {{ font-size: 0.84rem; color: var(--text-muted); margin-bottom: 0.25rem; }}
.upload-zone .upload-help {{ font-size: 0.75rem; color: var(--text-muted); margin-bottom: 1rem; font-style: italic; }}
.upload-zone input[type="file"] {{ font-family: var(--font); font-size: 0.84rem; }}
.upload-zone .upload-row {{ display: flex; gap: 0.5rem; align-items: center; justify-content: center; flex-wrap: wrap; }}
.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; }}
/* Orphan images */
.orphan-box {{ margin-bottom: 1rem; background: #fffbeb; border: 1px solid #fcd34d; border-radius: var(--radius); overflow: hidden; }}
.orphan-box summary {{ padding: 0.5rem 1rem; font-size: 0.82rem; font-weight: 600; color: #92400e; cursor: pointer; user-select: none; }}
.orphan-box summary:hover {{ color: #78350f; }}
.orphan-grid {{ display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 0.4rem; padding: 0.5rem 1rem 0.7rem; }}
.orphan-row {{ display: flex; align-items: center; gap: 0.5rem; font-size: 0.78rem; }}
.orphan-preview {{ color: #1a1a2e; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 200px; }}
.orphan-select {{ font-family: var(--font); font-size: 0.75rem; padding: 0.25rem 0.5rem; border: 1px solid #d1d5db; border-radius: 5px; background: #fff; max-width: 220px; }}
.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); }}
.empty {{ text-align: center; color: var(--text-muted); padding: 4rem; font-size: 1.1rem; }}
.rubric-legend {{ margin-bottom: 1.2rem; background: var(--surface); border-radius: var(--radius); box-shadow: var(--shadow-sm); overflow: hidden; }}
.rubric-legend summary {{ padding: 0.5rem 1rem; font-size: 0.78rem; font-weight: 600; color: var(--text-muted); cursor: pointer; user-select: none; }}
.rubric-legend summary:hover {{ color: var(--accent); }}
.rubric-grid {{ display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 0.3rem; padding: 0.5rem 1rem 0.7rem; }}
.rubric-item {{ font-size: 0.75rem; padding: 0.2rem 0.5rem; border-radius: 4px; background: var(--accent-ghost); }}
.rubric-item .rubric-id {{ font-weight: 600; color: var(--accent); margin-right: 0.3rem; }}
.rubric-item .rubric-sub {{ color: var(--text-muted); font-size: 0.7rem; }}
.rubric-item .rubric-lock {{ margin-left: 0.3rem; font-size: 0.65rem; }}
@media (max-width: 600px) {{
header {{ padding: 1.5rem 0; }}
header h1 {{ font-size: 1.2rem; }}
.container {{ padding: 0 0.8rem 1.5rem; }}
.toolbar {{ gap: 0.4rem; }}
.btn {{ padding: 0.4rem 0.7rem; font-size: 0.8rem; }}
.article-row {{ flex-wrap: wrap; }}
.article-id {{ order: -1; }}
}}
</style>
</head>
<body>
<header>
<div class="header-inner">
<h1>Artikel-Übersicht</h1>
<p class="subtitle">{count} Artikel im Archiv · <a href="/?key={ACCESS_TOKEN}">Aktualisieren</a><span class="token-indicator">{token_status}</span></p>
</div>
</header>
<div class="container">
<details class="rubric-legend">
<summary>📂 Rubriken (Kategorien) — zum Nachschlagen · 📌 Öffentliche = 3 Tage Hauptartikel</summary>
<div class="rubric-grid">
<div class="rubric-item"><span class="rubric-id">61</span> Korrektur <span class="rubric-lock">🔒 Redaktion</span></div>
<div class="rubric-item"><span class="rubric-id">9</span> Nachrichten</div>
<div class="rubric-item"><span class="rubric-id rubric-sub">└ 64</span> Angebot</div>
<div class="rubric-item"><span class="rubric-id rubric-sub">└ 65</span> Crowdfunding</div>
<div class="rubric-item"><span class="rubric-id rubric-sub">└ 66</span> Branche</div>
<div class="rubric-item"><span class="rubric-id rubric-sub">└ 67</span> Neuerscheinungen</div>
<div class="rubric-item"><span class="rubric-id rubric-sub">└ 68</span> Ankündigungen</div>
<div class="rubric-item"><span class="rubric-id rubric-sub">└ 69</span> Zubehör</div>
<div class="rubric-item"><span class="rubric-id">8</span> Brettspieltest</div>
<div class="rubric-item"><span class="rubric-id">11</span> Magazin</div>
<div class="rubric-item"><span class="rubric-id">10</span> Video</div>
<div class="rubric-item"><span class="rubric-id">55</span> Podcast</div>
</div>
</details>
{orphan_section}
<div class="toolbar">
<button class="btn btn-select" onclick="selectAll()">Alle auswählen</button>
<button class="btn btn-select" onclick="deselectAll()">Auswahl aufheben</button>
<button class="btn btn-upload" onclick="toggleUpload()">📷 Bild hochladen</button>
<label class="live-toggle-label" title="Ohne Haken: ALLE Artikel gehen auf Korrektur (Redaktion-only). MIT Haken: Live (öffentlich).">
<input type="checkbox" id="liveToggle" onchange="updateLiveMode()">
<span class="live-toggle-text">🔴 Live</span>
</label>
<button class="btn btn-publish" id="publishBtn" onclick="publishSelected()" disabled{publish_title}>📢 Veröffentlichen</button>
<button class="btn btn-delete" id="deleteBtn" onclick="deleteSelected()" disabled>Markierte löschen</button>
<span class="counter" id="counter">0 markiert</span>
</div>
<div class="upload-zone" id="uploadZone">
<h3>📷 Bild hochladen</h3>
<p style="margin-bottom:0.5rem;">Ziehe Bilder hierher oder klicke zum Auswählen.</p>
<p class="upload-help">Das Bild wird automatisch dem <strong>ersten markierten Artikel</strong> zugeordnet.<br>Oder nutze das 📷 Icon direkt neben dem gewünschten Artikel.</p>
<div class="upload-row">
<input type="file" id="fileInput" accept="image/*,.jpg,.jpeg,.png,.webp,.gif" onchange="uploadFile()" style="font-family:var(--font);font-size:0.84rem;">
</div>
<div class="upload-result" id="uploadResult"></div>
</div>
{items_html}
</div>
<div class="toast" id="toast"></div>
<script>
const checks = document.querySelectorAll('.article-check');
const deleteBtn = document.getElementById('deleteBtn');
const publishBtn = document.getElementById('publishBtn');
const counter = document.getElementById('counter');
const toast = document.getElementById('toast');
function updateUI() {{
const checked = document.querySelectorAll('.article-check:checked');
const count = checked.length;
counter.textContent = count + ' markiert';
deleteBtn.disabled = count === 0;
if (publishBtn) publishBtn.disabled = count === 0;
document.querySelectorAll('.article-row').forEach(row => row.classList.remove('selected'));
checked.forEach(cb => cb.closest('.article-row').classList.add('selected'));
}}
checks.forEach(cb => cb.addEventListener('change', updateUI));
function selectAll() {{ checks.forEach(cb => {{ cb.checked = true; }}); updateUI(); }}
function deselectAll() {{ checks.forEach(cb => {{ cb.checked = false; }}); updateUI(); }}
function toggleUpload() {{
const zone = document.getElementById('uploadZone');
zone.classList.toggle('active');
}}
function uploadFile() {{
const file = document.getElementById('fileInput').files[0];
if (!file) return;
// Nimm den ersten markierten Artikel als Ziel
const checked = document.querySelectorAll('.article-check:checked');
if (checked.length === 0) {{
showToast('Bitte zuerst einen Artikel markieren (Checkbox links)', true);
return;
}}
const articleFile = checked[0].value;
const formData = new FormData();
formData.append('file', file);
formData.append('article', articleFile);
const result = document.getElementById('uploadResult');
result.textContent = 'Lade hoch…';
result.classList.add('show');
fetch('/upload?key={ACCESS_TOKEN}', {{ method: 'POST', body: formData }})
.then(r => r.json())
.then(data => {{
if (data.ok) {{
result.textContent = '' + file.name + '' + articleFile;
result.style.background = '#f0fdf4';
showToast('✅ Bild zugeordnet: ' + articleFile.split('_2026')[0].replace(/_/g,' ').replace('artikel ',''), false);
// Reload nach 1.5s um Badge-Counter zu aktualisieren
setTimeout(() => location.reload(), 1500);
}} else {{
result.textContent = '' + (data.error || 'Fehler');
result.style.background = '#fef2f2';
}}
}})
.catch(e => {{
result.textContent = '' + e.message;
result.style.background = '#fef2f2';
}});
}}
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', '<span class=\"img-badge\">🖼1</span>');
}}
}}
// 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);
}}
}}
async function assignOrphan(select, imagePath) {{
const article = select.value;
if (!article) return;
try {{
const resp = await fetch('/assign-image?key={ACCESS_TOKEN}', {{
method: 'POST',
headers: {{'Content-Type': 'application/json'}},
body: JSON.stringify({{image: imagePath, article: article}})
}});
const data = await resp.json();
if (data.ok) {{
showToast('✅ Bild zugeordnet', false);
select.closest('.orphan-row').style.opacity = '0.4';
select.disabled = true;
setTimeout(() => location.reload(), 1200);
}} else {{
showToast('' + (data.error || 'Fehler'), true);
}}
}} catch(e) {{
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'));
uploadZone.addEventListener('drop', e => {{
e.preventDefault();
uploadZone.classList.remove('dragover');
document.getElementById('fileInput').files = e.dataTransfer.files;
uploadFile();
}});
function showToast(msg, isError) {{
toast.textContent = msg;
toast.className = 'toast' + (isError ? ' error' : '') + ' show';
setTimeout(() => toast.classList.remove('show'), 3000);
}}
function updateLiveMode() {{
const live = document.getElementById('liveToggle').checked;
const publishBtn = document.getElementById('publishBtn');
if (live) {{
publishBtn.textContent = '🔴 LIVE veröffentlichen';
publishBtn.style.background = '#dc3545';
publishBtn.style.boxShadow = '0 2px 8px rgba(220,53,69,0.25)';
}} else {{
publishBtn.textContent = '📢 Veröffentlichen';
publishBtn.style.background = '';
publishBtn.style.boxShadow = '';
}}
}}
async function publishSelected() {{
const selected = Array.from(document.querySelectorAll('.article-check:checked')).map(cb => cb.value);
if (selected.length === 0) return;
const live = document.getElementById('liveToggle').checked;
// Readiness-Warnung (soft, blockiert nicht)
const notReady = [];
document.querySelectorAll('.article-check:checked').forEach(cb => {{
const row = cb.closest('.article-row');
if (row && row.dataset.ready === 'false') {{
notReady.push(row.querySelector('.article-link')?.textContent || cb.value);
}}
}});
let confirmMsg;
if (live) {{
confirmMsg = '🔴 LIVE: ' + selected.length + ' Artikel ÖFFENTLICH veröffentlichen?\\n\\nDies ist für 10.000+ Leser sichtbar!';
}} else {{
confirmMsg = selected.length + ' Artikel auf KORREKTUR hochladen?\\n\\nNur Redaktion sichtbar. Sicher.';
}}
if (notReady.length > 0) {{
confirmMsg += '\\n\\n⚠️ ' + notReady.length + ' Artikel ohne Meta-Daten/Bild.';
}}
if (!confirm(confirmMsg)) return;
publishBtn.disabled = true;
publishBtn.textContent = 'Veröffentliche…';
try {{
const auth = btoa('{USERNAME}:{PASSWORD}');
const resp = await fetch('/publish', {{
method: 'POST',
headers: {{'Content-Type': 'application/json', 'Authorization': 'Basic ' + auth}},
body: JSON.stringify({{files: selected, live: live}})
}});
const data = await resp.json();
if (data.ok) {{
showToast(data.published + ' Artikel veröffentlicht (' + (data.live_mode ? 'LIVE' : 'Korrektur') + '). ' + (data.images_deleted || 0) + ' Bilder gelöscht. Seite wird neu geladen...', false);
setTimeout(() => location.reload(), 2000);
}} else {{
showToast('Fehler: ' + (data.error || 'unbekannt'), true);
}}
}} catch(e) {{
showToast('Fehler: ' + e.message, true);
}}
publishBtn.disabled = false;
publishBtn.textContent = '📢 Veröffentlichen';
}}
async function deleteSelected() {{
const selected = Array.from(document.querySelectorAll('.article-check:checked')).map(cb => cb.value);
if (selected.length === 0) return;
if (!confirm(selected.length + ' Artikel wirklich löschen? Das kann nicht rückgängig gemacht werden.')) return;
deleteBtn.disabled = true;
deleteBtn.textContent = 'Lösche…';
try {{
const auth = btoa('{USERNAME}:{PASSWORD}');
const resp = await fetch('/delete', {{
method: 'POST',
headers: {{'Content-Type': 'application/json', 'Authorization': 'Basic ' + auth}},
body: JSON.stringify({{files: selected}})
}});
const data = await resp.json();
if (data.ok) {{
showToast(data.deleted + ' Artikel gelöscht. Seite wird neu geladen...', false);
setTimeout(() => location.reload(), 1500);
}} else {{
showToast('Fehler: ' + (data.error || 'unbekannt'), true);
}}
}} catch(e) {{
showToast('Fehler: ' + e.message, true);
}}
deleteBtn.disabled = false;
deleteBtn.textContent = 'Markierte löschen';
}}
</script>
</body>
</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 = """<!DOCTYPE html>
<html lang="de">
<head><meta charset="UTF-8"><title>Zugriff verweigert</title>
<style>body{font-family:sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;background:#f5f0ea;color:#2c2416;}
.box{text-align:center;padding:2rem;background:#fff;border-radius:8px;box-shadow:0 2px 12px rgba(0,0,0,.08);}
h1{color:#c0392b;}p{color:#888;}</style></head>
<body><div class="box"><h1>Zugriff verweigert</h1><p>Bitte einloggen, um die Artikel-Übersicht zu sehen.</p></div></body></html>"""
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 = "<!DOCTYPE html><html lang=\"de\"><head><meta charset=\"UTF-8\"><title>Zugriff verweigert</title><style>body{font-family:sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;background:#f5f0ea;color:#2c2416;}.box{text-align:center;padding:2rem;background:#fff;border-radius:8px;box-shadow:0 2px 12px rgba(0,0,0,.08);}h1{color:#c0392b;}p{color:#888;}</style></head><body><div class=\"box\"><h1>Zugriff verweigert</h1><p>Diese Seite ist nicht öffentlich.</p></div></body></html>"
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
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'<title>([^<]+)</title>', html_content)
if not title_match:
errors.append(f"{fname}: kein <title> 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
# ── SAFETY GATE: Ohne live=true ALLES auf Korrektur ─────
# Extract category from meta NUR wenn live explizit erlaubt
catid = 61 # Default: Korrektur (Redaktion-only, nicht öffentlich)
if 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,
}
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,
"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()