32d1e1ef86
- Text-Input für Artikel-Dateinamen komplett entfernt - uploadFile() nimmt jetzt den ERSTEN markierten Artikel (Checkbox) - Drag&Drop-Zone oben: Ziel = markierter Artikel oder Hinweis - JPEG-Accept verbessert: image/* + explizit .jpg,.jpeg,.png,.webp,.gif - Obsolete CSS für input[type=text] in upload-zone entfernt - Nach Upload: Auto-Reload für Badge-Update
1203 lines
56 KiB
Python
1203 lines
56 KiB
Python
#!/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 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()
|
||
|
||
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; }}
|
||
|
||
.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>
|
||
<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);
|
||
}}
|
||
}}
|
||
|
||
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 == "/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_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()
|