ed63994e1c
- Excel (.xlsx) für Fakten/Wertung/Pro/Kontra/Tags - Word (.docx) für Artikeltext (Spielerklärung) - Beide optional — Text aus Word wird zwischen Infobox und Fazit eingefügt - Formular zeigt beide Upload-Zonen nebeneinander
2878 lines
131 KiB
Python
2878 lines
131 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.
|
||
v3: Added NanoBanana Image Generator (/nanobanana)."""
|
||
|
||
import os
|
||
import re
|
||
import glob
|
||
import json
|
||
import base64
|
||
import hashlib
|
||
import socket
|
||
import urllib.parse
|
||
import urllib.request
|
||
import io
|
||
import email.parser
|
||
import tempfile
|
||
import subprocess
|
||
import time
|
||
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_"
|
||
REVIEW_CONFIG_PATH = os.path.join(WORKSPACE, "review_config.json")
|
||
REVIEW_CONFIG = {}
|
||
if os.path.exists(REVIEW_CONFIG_PATH):
|
||
with open(REVIEW_CONFIG_PATH, "r") as f:
|
||
REVIEW_CONFIG = json.load(f)
|
||
|
||
# ── 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
|
||
}
|
||
|
||
# ── Gemini Flash Image API Configuration ────────────────────────────────────
|
||
GEMINI_IMAGE_MODEL = "gemini-2.5-flash-image"
|
||
GEMINI_API_URL = (
|
||
"https://generativelanguage.googleapis.com/v1beta/"
|
||
"models/" + GEMINI_IMAGE_MODEL + ":generateContent"
|
||
)
|
||
GEMINI_KEY_SECRET = "bsn/google-imagen-key"
|
||
|
||
|
||
def _get_gemini_key() -> str:
|
||
"""Google Gemini API-Key aus bsn-secrets lesen."""
|
||
try:
|
||
r = subprocess.run(
|
||
["/home/hermes/bin/bsn-secrets", "get", GEMINI_KEY_SECRET],
|
||
capture_output=True, text=True, timeout=5
|
||
)
|
||
return r.stdout.strip()
|
||
except Exception:
|
||
return ""
|
||
|
||
|
||
def _detect_image_mime(image_bytes: bytes) -> str:
|
||
"""MIME-Type anhand Magic-Bytes erkennen."""
|
||
if len(image_bytes) < 8:
|
||
return "image/jpeg" # Fallback
|
||
if image_bytes[:4] == b"\x89PNG":
|
||
return "image/png"
|
||
if image_bytes[:2] == b"\xff\xd8":
|
||
return "image/jpeg"
|
||
if image_bytes[:4] == b"RIFF" and image_bytes[8:12] == b"WEBP":
|
||
return "image/webp"
|
||
if image_bytes[:4] in (b"ftyp", b"heic", b"heix", b"hevc", b"hevx"):
|
||
return "image/heic"
|
||
return "image/jpeg" # Fallback
|
||
|
||
|
||
def _gemini_generate_image(
|
||
prompt: str,
|
||
image_base64: str | None,
|
||
aspect_ratio: str,
|
||
resolution: str
|
||
) -> dict:
|
||
"""Gemini Flash Image aufrufen — Text-to-Image und Image-to-Image.
|
||
|
||
Args:
|
||
prompt: Text-Prompt.
|
||
image_base64: Optionales Referenzbild als Base64 (Image-to-Image).
|
||
aspect_ratio: Seitenverhältnis (z.B. "16:9").
|
||
resolution: Behält Kompatibilität (von Gemini ignoriert).
|
||
|
||
Returns:
|
||
dict mit "image_base64" (PNG) oder "error".
|
||
"""
|
||
import base64 as b64
|
||
|
||
key = _get_gemini_key()
|
||
if not key:
|
||
return {"error": "Kein Gemini API-Key"}
|
||
|
||
parts = [{"text": f"{prompt}\n\nAspect ratio: {aspect_ratio}."}]
|
||
if image_base64:
|
||
# MIME-Type aus den tatsächlichen Bilddaten erkennen
|
||
raw_bytes = b64.b64decode(image_base64)
|
||
mime_type = _detect_image_mime(raw_bytes)
|
||
parts.append({
|
||
"inlineData": {
|
||
"mimeType": mime_type,
|
||
"data": image_base64
|
||
}
|
||
})
|
||
|
||
body = json.dumps({
|
||
"contents": [{"parts": parts}],
|
||
"generationConfig": {
|
||
"responseModalities": ["image", "text"]
|
||
}
|
||
}).encode()
|
||
|
||
req = urllib.request.Request(GEMINI_API_URL, data=body, method="POST")
|
||
req.add_header("x-goog-api-key", key)
|
||
req.add_header("Content-Type", "application/json")
|
||
|
||
try:
|
||
with urllib.request.urlopen(req, timeout=45) as resp:
|
||
result = json.loads(resp.read())
|
||
candidates = result.get("candidates") or []
|
||
for cand in candidates:
|
||
for part in cand.get("content", {}).get("parts", []):
|
||
inline = part.get("inlineData")
|
||
if inline and inline.get("data"):
|
||
return {"image_base64": inline["data"]}
|
||
return {"error": "Kein Bild in Gemini-Antwort"}
|
||
except urllib.error.HTTPError as e:
|
||
return {"error": f"HTTP {e.code}: {e.read().decode()[:300]}"}
|
||
|
||
def _get_unpublished_articles() -> list[dict]:
|
||
"""Liste aller unveröffentlichten Artikel (ohne joomla-id)."""
|
||
articles = []
|
||
for f in sorted(glob.glob(os.path.join(WORKSPACE, "*.html")), key=os.path.getmtime, reverse=True):
|
||
name = os.path.basename(f)
|
||
if any(re.search(p, name) for p in SKIP_PATTERNS):
|
||
continue
|
||
try:
|
||
with open(f, "r") as fh:
|
||
content = fh.read(5000)
|
||
has_joomla_id = '<meta name="joomla-id"' in content
|
||
if not has_joomla_id:
|
||
title_match = re.search(r'<title>(.*?)</title>', content)
|
||
title = title_match.group(1).strip() if title_match else name
|
||
articles.append({"filename": name, "title": title})
|
||
except Exception:
|
||
pass
|
||
return articles[:50] # Max 50
|
||
|
||
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',
|
||
r'kinderspiele_deals', r'amazon_deals', r'amazon_primeday',
|
||
]
|
||
|
||
|
||
# ── 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*(?:Januar|Februar|März|April|Mai|Juni|Juli|August|September|Oktober|November|Dezember)\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+|XXXXX)"', 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" 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; }}
|
||
|
||
.rubric-live-select {{ font-family: var(--font); font-size: 0.8rem; padding: 0.35rem 0.7rem; border: 1px solid var(--border); border-radius: 7px; background: var(--surface); color: var(--text); cursor: pointer; }}
|
||
.rubric-live-select:focus {{ outline: none; border-color: var(--accent); box-shadow: 0 0 0 2px rgba(32,34,138,0.15); }}
|
||
.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> · <a href="/review-builder?key={ACCESS_TOKEN}" style="font-weight:600;color:#10B981">📝 Review-Builder</a> · <a href="/nanobanana?key={ACCESS_TOKEN}" style="font-weight:600">🎨 Bild-Generator</a> · <a href="/fehler?key={ACCESS_TOKEN}" style="font-weight:600">🐛 Fehler</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>
|
||
<select id="rubricSelect" class="rubric-live-select" onchange="updateLiveMode()">
|
||
<option value="0">📂 Rubrik wählen…</option>
|
||
<option value="9">📰 Nachrichten</option>
|
||
<option value="64" class="sub">└ Angebot</option>
|
||
<option value="65" class="sub">└ Crowdfunding</option>
|
||
<option value="66" class="sub">└ Branche</option>
|
||
<option value="67" class="sub">└ Neuerscheinungen</option>
|
||
<option value="68" class="sub">└ Ankündigungen</option>
|
||
<option value="69" class="sub">└ Zubehör</option>
|
||
<option value="8">🎲 Brettspieltest</option>
|
||
<option value="11">📖 Magazin</option>
|
||
<option value="10">🎬 Video</option>
|
||
<option value="55">🎙 Podcast</option>
|
||
</select>
|
||
<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" 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;
|
||
const rubric = document.getElementById('rubricSelect').value;
|
||
|
||
if (live && rubric === '0') {{
|
||
showToast('❌ Bitte eine Rubrik wählen (Dropdown rechts neben 🔴 Live)', true);
|
||
return;
|
||
}}
|
||
|
||
// 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) {{
|
||
const rubricName = document.getElementById('rubricSelect').selectedOptions[0].text;
|
||
confirmMsg = '🔴 LIVE in »' + rubricName + '«: ' + 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, rubric: parseInt(rubric)}})
|
||
}});
|
||
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 == "/nanobanana":
|
||
html = self._build_nanobanana_page()
|
||
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 == "/nanobanana/articles":
|
||
self._handle_nanobanana_articles()
|
||
elif path == "/fehler":
|
||
html = self._build_fehler_page()
|
||
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 == "/review-builder":
|
||
html = self._build_review_builder_form()
|
||
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 == "/nanobanana/generate":
|
||
if not self._check_token():
|
||
return
|
||
self._handle_image_generate()
|
||
elif path == "/nanobanana/articles":
|
||
if not self._check_token():
|
||
return
|
||
self._handle_nanobanana_articles()
|
||
elif path == "/nanobanana/assign":
|
||
if not self._check_token():
|
||
return
|
||
self._handle_nanobanana_assign()
|
||
elif path == "/review-builder/build":
|
||
if not self._check_token():
|
||
return
|
||
self._handle_review_builder_build()
|
||
elif path == "/fehler":
|
||
html = self._build_fehler_page()
|
||
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 == "/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()
|
||
|
||
# ═══════════════════════════════════════════════════════════════════
|
||
# Imagen Handler (Google Imagen — synchron, kein Polling)
|
||
# ═══════════════════════════════════════════════════════════════════
|
||
|
||
def _handle_image_generate(self):
|
||
"""Bildgenerierung via Google Imagen (synchron, Base64→JPEG)."""
|
||
import base64 as b64
|
||
|
||
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
|
||
|
||
boundary = content_type.split("boundary=")[-1].strip()
|
||
if not boundary:
|
||
self._json_reply(400, {"ok": False, "error": "Kein Boundary"})
|
||
return
|
||
|
||
length = int(self.headers.get("Content-Length", 0))
|
||
raw = self.rfile.read(length)
|
||
|
||
# Multipart manuell parsen (Python 3.13 hat kein cgi)
|
||
parts = raw.split(b"--" + boundary.encode())
|
||
prompt = ""
|
||
aspect_ratio = "16:9"
|
||
resolution = "1K"
|
||
image_base64 = None
|
||
|
||
for part in parts:
|
||
if b"Content-Disposition" not in part:
|
||
continue
|
||
headers_end = part.find(b"\r\n\r\n")
|
||
if headers_end == -1:
|
||
continue
|
||
headers_raw = part[:headers_end].decode("utf-8", errors="ignore")
|
||
content = part[headers_end + 4:].rstrip(b"\r\n")
|
||
|
||
if 'name="prompt"' in headers_raw:
|
||
prompt = content.decode("utf-8", errors="ignore")
|
||
elif 'name="aspectRatio"' in headers_raw:
|
||
aspect_ratio = content.decode("utf-8", errors="ignore")
|
||
elif 'name="resolution"' in headers_raw:
|
||
resolution = content.decode("utf-8", errors="ignore")
|
||
elif 'name="image"' in headers_raw and len(content) > 100:
|
||
# Referenzbild als Base64 für Image-to-Image
|
||
image_base64 = b64.b64encode(content).decode()
|
||
|
||
if not prompt:
|
||
self._json_reply(400, {"ok": False, "error": "Prompt fehlt"})
|
||
return
|
||
|
||
# Gemini Flash Image aufrufen (synchron!)
|
||
result = _gemini_generate_image(prompt, image_base64, aspect_ratio, resolution)
|
||
if "error" in result:
|
||
self._json_reply(500, {"ok": False, "error": result["error"]})
|
||
return
|
||
|
||
# Base64-Bild (PNG) dekodieren
|
||
b64_data = result.get("image_base64", "")
|
||
if not b64_data:
|
||
self._json_reply(500, {"ok": False, "error": "Leeres Bild von Gemini"})
|
||
return
|
||
|
||
img_bytes = b64.b64decode(b64_data)
|
||
|
||
# Im media-Ordner speichern
|
||
now = datetime.now()
|
||
month_dir = os.path.join(MEDIA_DIR, now.strftime("%Y"), now.strftime("%m"))
|
||
os.makedirs(month_dir, exist_ok=True)
|
||
base_name = f"gemini_{now.strftime('%H%M%S')}"
|
||
ext = ".png" # Gemini liefert PNG
|
||
filepath = os.path.join(month_dir, base_name + ext)
|
||
|
||
with open(filepath, "wb") as f:
|
||
f.write(img_bytes)
|
||
|
||
# Optional: PIL-Konvertierung zu JPEG (1920x1080 @ 72 DPI)
|
||
try:
|
||
from PIL import Image
|
||
img = Image.open(filepath)
|
||
img = img.convert("RGB")
|
||
img = img.resize((1920, 1080), Image.LANCZOS)
|
||
jpg_path = os.path.join(month_dir, base_name + ".jpg")
|
||
img.save(jpg_path, "JPEG", dpi=(72, 72), quality=92)
|
||
filepath = jpg_path
|
||
ext = ".jpg"
|
||
except Exception:
|
||
pass # PNG behalten wenn PIL fehlschlägt
|
||
|
||
img_url = f"/media/{now.strftime('%Y')}/{now.strftime('%m')}/{base_name}{ext}?key={ACCESS_TOKEN}"
|
||
self._json_reply(200, {
|
||
"ok": True,
|
||
"imageUrl": img_url,
|
||
"filepath": filepath,
|
||
})
|
||
|
||
def _handle_nanobanana_articles(self):
|
||
"""Liste unveröffentlichter Artikel als JSON."""
|
||
articles = _get_unpublished_articles()
|
||
self._json_reply(200, {"ok": True, "articles": articles})
|
||
|
||
def _handle_nanobanana_assign(self):
|
||
"""Bild einem Artikel zuordnen."""
|
||
content_type = self.headers.get("Content-Type", "")
|
||
if "application/json" not in content_type:
|
||
self._json_reply(400, {"ok": False, "error": "Expected JSON"})
|
||
return
|
||
|
||
length = int(self.headers.get("Content-Length", 0))
|
||
raw = self.rfile.read(length)
|
||
try:
|
||
data = json.loads(raw)
|
||
except json.JSONDecodeError:
|
||
self._json_reply(400, {"ok": False, "error": "Invalid JSON"})
|
||
return
|
||
|
||
article = data.get("article", "")
|
||
image_url = data.get("imageUrl", "")
|
||
|
||
if not article or not image_url:
|
||
self._json_reply(400, {"ok": False, "error": "article und imageUrl erforderlich"})
|
||
return
|
||
|
||
# Bild herunterladen oder lokal lesen
|
||
try:
|
||
if image_url.startswith("/media/"):
|
||
# Lokales Bild — direkt von Platte lesen
|
||
local_path = os.path.join(WORKSPACE, image_url.lstrip("/"))
|
||
if not os.path.isfile(local_path):
|
||
self._json_reply(500, {"ok": False, "error": f"Lokales Bild nicht gefunden: {local_path}"})
|
||
return
|
||
with open(local_path, "rb") as lf:
|
||
img_data = lf.read()
|
||
else:
|
||
req = urllib.request.Request(image_url)
|
||
req.add_header("User-Agent", "Mozilla/5.0")
|
||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||
img_data = resp.read()
|
||
except Exception as e:
|
||
self._json_reply(500, {"ok": False, "error": f"Download fehlgeschlagen: {e}"})
|
||
return
|
||
|
||
# In media/YYYY/MM/ speichern
|
||
now = datetime.now()
|
||
month_dir = os.path.join(MEDIA_DIR, now.strftime("%Y"), now.strftime("%m"))
|
||
os.makedirs(month_dir, exist_ok=True)
|
||
ts = now.strftime("%H%M%S")
|
||
fname = f"nanobanana_{ts}.jpg"
|
||
fpath = os.path.join(month_dir, fname)
|
||
|
||
with open(fpath, "wb") as f:
|
||
f.write(img_data)
|
||
|
||
# article_images.json updaten
|
||
img_map = get_image_map()
|
||
if article not in img_map:
|
||
img_map[article] = []
|
||
img_map[article].append(fpath)
|
||
save_image_map(img_map)
|
||
|
||
self._json_reply(200, {"ok": True, "path": fpath, "filename": fname})
|
||
|
||
def _build_nanobanana_page(self) -> str:
|
||
"""Baut die NanoBanana Editor HTML-Seite."""
|
||
articles = _get_unpublished_articles()
|
||
article_options = ""
|
||
for a in articles[:30]:
|
||
article_options += f'<option value="{a["filename"]}">{a["title"]}</option>\n'
|
||
|
||
return NANOBANANA_HTML.format(
|
||
key=ACCESS_TOKEN,
|
||
article_options=article_options,
|
||
)
|
||
|
||
# ═══════════════════════════════════════════════════════════════════
|
||
# End NanoBanana Handler
|
||
# ═══════════════════════════════════════════════════════════════════
|
||
|
||
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 _build_fehler_page(self) -> str:
|
||
"""Baut die Fehler-Dashboard-Seite mit Daten vom Fehler-Server."""
|
||
stats = {"total": 0, "offen": 0, "behoben": 0, "verworfen": 0}
|
||
meldungen = []
|
||
|
||
try:
|
||
req = urllib.request.Request("http://127.0.0.1:8888/api/stats")
|
||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||
stats = json.loads(resp.read())
|
||
except Exception as e:
|
||
stats["error"] = str(e)
|
||
|
||
try:
|
||
req = urllib.request.Request("http://127.0.0.1:8888/api/meldungen")
|
||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||
meldungen = json.loads(resp.read())
|
||
except Exception:
|
||
pass
|
||
|
||
status_labels = {
|
||
"offen": '<span style="background:#fef3c7;color:#92400e;">offen</span>',
|
||
"in_bearbeitung": '<span style="background:#dbeafe;color:#1e40af;">in Bearbeitung</span>',
|
||
"behoben": '<span style="background:#def7ec;color:#065f46;">behoben</span>',
|
||
"verworfen": '<span style="background:#f3f4f6;color:#6b7280;">verworfen</span>',
|
||
}
|
||
|
||
rows = ""
|
||
for m in meldungen[:200]:
|
||
text = (m.get("fehler_text") or "")[:100]
|
||
rows += (
|
||
'<tr><td>{id}</td>'
|
||
'<td><a href="https://www.brettspiel-news.de/index.php/de/component/content/article/{article_id}" target="_blank">#{article_id}</a></td>'
|
||
'<td>{name}</td><td title="{full}">{text}</td>'
|
||
'<td>{status}</td><td style="color:#6b7280;font-size:0.8rem;">{loesung}</td>'
|
||
'<td style="font-size:0.8rem;">{date}</td></tr>'
|
||
).format(
|
||
id=m.get("id"),
|
||
article_id=m.get("article_id"),
|
||
name=m.get("melder_name", ""),
|
||
text=text + ("…" if len(m.get("fehler_text") or "") > 100 else ""),
|
||
full=(m.get("fehler_text") or "").replace('"', """),
|
||
status=status_labels.get(m.get("status"), m.get("status", "")),
|
||
loesung=m.get("loesung") or "—",
|
||
date=(m.get("created_at") or "")[:10],
|
||
)
|
||
|
||
if not rows:
|
||
rows = '<tr><td colspan="8" style="padding:20px;text-align:center;color:#9ca3af;">Keine Meldungen</td></tr>'
|
||
|
||
return """<!DOCTYPE html>
|
||
<html lang="de">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Fehlermeldungen - BSN</title>
|
||
<style>
|
||
:root {{ --bsn: #20228a; --bg: #F9FAFB; --card: #fff; --border: #e5e7eb; --text: #1F2937; }}
|
||
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
|
||
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: var(--bg); color: var(--text); line-height: 1.5; padding: 20px; max-width: 1200px; margin: 0 auto; }}
|
||
header {{ background: var(--bsn); color: #fff; padding: 20px 24px; border-radius: 10px; margin-bottom: 20px; }}
|
||
header h1 {{ font-size: 1.4rem; }}
|
||
header a {{ color: #a5b4fc; font-size: 0.9rem; }}
|
||
.stats {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: 12px; margin-bottom: 20px; }}
|
||
.stat {{ background: var(--card); border: 1px solid var(--border); border-radius: 8px; padding: 14px; text-align: center; }}
|
||
.stat .num {{ font-size: 1.8rem; font-weight: 800; }}
|
||
.stat .lbl {{ font-size: 0.75rem; color: #6b7280; margin-top: 2px; }}
|
||
table {{ width: 100%; border-collapse: collapse; background: var(--card); border: 1px solid var(--border); border-radius: 8px; overflow: hidden; }}
|
||
th {{ background: #f8fafc; padding: 8px 10px; font-size: 0.8rem; text-align: left; font-weight: 600; }}
|
||
td {{ padding: 6px 10px; border-bottom: 1px solid var(--border); font-size: 0.85rem; }}
|
||
tr:last-child td {{ border-bottom: none; }}
|
||
.c-total {{ color: var(--bsn); }} .c-offen {{ color: #f39c12; }} .c-behoben {{ color: #27ae60; }} .c-verworfen {{ color: #9ca3af; }}
|
||
span[style] {{ display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 0.7rem; font-weight: 600; }}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<header>
|
||
<h1>🐛 Fehlermeldungen</h1>
|
||
<a href="/?key={ACCESS_TOKEN}">← Zurück zur Artikel-Übersicht</a>
|
||
</header>
|
||
<div class="stats">
|
||
<div class="stat"><div class="num c-total">{total}</div><div class="lbl">Gesamt</div></div>
|
||
<div class="stat"><div class="num c-offen">{offen}</div><div class="lbl">Offen</div></div>
|
||
<div class="stat"><div class="num c-behoben">{behoben}</div><div class="lbl">Behoben</div></div>
|
||
<div class="stat"><div class="num c-verworfen">{verworfen}</div><div class="lbl">Verworfen</div></div>
|
||
</div>
|
||
<table>
|
||
<thead><tr>
|
||
<th>ID</th><th>Artikel</th><th>Name</th><th>Fehler</th>
|
||
<th>Status</th><th>Lösung</th><th>Datum</th>
|
||
</tr></thead>
|
||
<tbody>{rows}</tbody>
|
||
</table>
|
||
</body>
|
||
</html>""".format(
|
||
ACCESS_TOKEN=ACCESS_TOKEN,
|
||
total=stats.get("total", 0),
|
||
offen=stats.get("offen", 0),
|
||
behoben=stats.get("behoben", 0),
|
||
verworfen=stats.get("verworfen", 0),
|
||
rows=rows,
|
||
)
|
||
|
||
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
|
||
rubric = data.get("rubric", 0) # 0 = nicht gesetzt
|
||
except json.JSONDecodeError:
|
||
self._json_reply(400, {"ok": False, "error": "Invalid JSON"})
|
||
return
|
||
|
||
published = 0
|
||
published_ids = []
|
||
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
|
||
|
||
# ── Strip internal markers from article body ──────────────
|
||
# These are editorial metadata, not part of the published article.
|
||
# Applied AFTER extraction but BEFORE read-more split.
|
||
article_body = re.sub(r'<h1[^>]*>.*?</h1>\s*', '', article_body, flags=re.DOTALL)
|
||
article_body = re.sub(r'<p\s+class="meta"[^>]*>.*?</p>\s*', '', article_body, flags=re.DOTALL)
|
||
article_body = re.sub(r'<p\s+class="qc"[^>]*>.*?</p>\s*', '', article_body, flags=re.DOTALL)
|
||
# ── Fallback: strip any remaining h1 or meta (edge cases) ──
|
||
article_body = re.sub(r'<h1\b[^>]*>.*?</h1>\s*', '', article_body, flags=re.DOTALL | re.IGNORECASE)
|
||
article_body = re.sub(r'<p\b[^>]*\bclass\s*=\s*"[^"]*\bmeta\b[^"]*"[^>]*>.*?</p>\s*', '', article_body, flags=re.DOTALL | re.IGNORECASE)
|
||
# ── Umbruch vor Überschriften (Daniel 19.06.: bessere Lesbarkeit) ──
|
||
article_body = re.sub(r'(</(?:p|/div|/blockquote)>)\s*(<h[234])', r'\1\n<br>\n\2', article_body)
|
||
# ── Remove <br> after read-more (muss NACH Umbruch-Insertion laufen!) ──
|
||
article_body = re.sub(
|
||
r'(<hr\s+id="system-readmore"\s*/?>)\s*<br\s*/?>\s*',
|
||
r'\1\n',
|
||
article_body, flags=re.IGNORECASE
|
||
)
|
||
# ── Strip trailing whitespace/newlines before closing </article> ──
|
||
article_body = re.sub(r'\s*</article>', '</article>', article_body)
|
||
|
||
# Extract Joomla ID if present (for updates)
|
||
jid_match = re.search(r'<meta\s+name="joomla-id"\s+content="(\d+|XXXXX)"', html_content, re.IGNORECASE)
|
||
joomla_id = jid_match.group(1) if jid_match else None
|
||
|
||
# ── Kategorie: Rubrik vom Dropdown oder meta ──────────
|
||
catid = 61 # Default: Korrektur
|
||
if allow_live and rubric > 0:
|
||
catid = rubric
|
||
elif 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 IMMER einbauen (auch bei Korrektur) ──
|
||
# Daniel 19.06.: Pixel muss bei JEDEM Publish rein, weil er
|
||
# später manuell live stellt und sonst kein Geld bekommt.
|
||
vgwort_pixel_html = ""
|
||
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)
|
||
|
||
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,
|
||
}
|
||
|
||
# Joomla 5/6 PATCH-Fix: articletext wird ignoriert, muss in introtext + fulltext aufgeteilt werden
|
||
introtext = ""
|
||
fulltext = article_body.strip()
|
||
sr_match = re.search(r'<hr\s+id="system-readmore"\s*/?>', article_body, re.IGNORECASE)
|
||
if sr_match:
|
||
split_idx = sr_match.start()
|
||
introtext = article_body[:split_idx].strip()
|
||
fulltext = article_body[split_idx:].strip()
|
||
# Remove the <hr id="system-readmore" /> from fulltext start
|
||
# (Joomla inserts its own read-more between introtext and fulltext)
|
||
fulltext = re.sub(r'^<hr\s+id="system-readmore"\s*/?>\s*', '', fulltext)
|
||
|
||
# ── CSS aus <head> in fulltext injizieren ──────────────────
|
||
# Problem: <style> liegt außerhalb <article>, wird beim
|
||
# Body-Extract nicht mitgenommen → Tabellen-Styling fehlt.
|
||
style_match = re.search(r'<style>(.*?)</style>', html_content, re.DOTALL)
|
||
if style_match:
|
||
style_css = style_match.group(1).strip()
|
||
fulltext = f'<style>\n{style_css}\n</style>\n\n{fulltext}'
|
||
|
||
# ── target="_blank" für externe Links erzwingen ──────
|
||
# Daniel 24.06.: Alle externen Links müssen _blank sein,
|
||
# damit brettspiel-news.de nicht verlassen wird.
|
||
def add_target_blank(m):
|
||
href = m.group(1)
|
||
rest = m.group(2)
|
||
if 'brettspiel-news.de' in href:
|
||
return m.group(0) # interne Links unverändert
|
||
if 'target=' in rest:
|
||
return m.group(0) # hat bereits target
|
||
return f'<a href="{href}" target="_blank"{rest}>'
|
||
fulltext = re.sub(r'<a\s+href="([^"]+)"([^>]*)>', add_target_blank, fulltext)
|
||
introtext = re.sub(r'<a\s+href="([^"]+)"([^>]*)>', add_target_blank, introtext)
|
||
|
||
article_payload = {
|
||
"title": title,
|
||
"alias": alias,
|
||
"introtext": introtext,
|
||
"fulltext": fulltext,
|
||
"catid": catid,
|
||
"language": "*",
|
||
"state": state,
|
||
"access": access,
|
||
"featured": featured,
|
||
"created_by": 560, # Daniel Krause
|
||
}
|
||
# ── Meta-Description & Keywords ─────────────────────────
|
||
if meta_desc_match and meta_desc_match.group(1).strip():
|
||
article_payload["metadesc"] = meta_desc_match.group(1).strip()
|
||
if meta_key_match and meta_key_match.group(1).strip():
|
||
article_payload["metakey"] = meta_key_match.group(1).strip()
|
||
# ── Image upload to Joomla (Joomla 5/6: Base64 + json, nicht multipart) ──
|
||
if fname in img_map and img_map[fname]:
|
||
image_path = img_map[fname][0] # First image
|
||
if os.path.exists(image_path):
|
||
try:
|
||
import base64 as b64
|
||
from datetime import datetime as dt
|
||
img_filename = os.path.basename(image_path)
|
||
year = dt.now().strftime("%Y")
|
||
j_img_path = f"Spiele/{year}/{img_filename}" # OHNE images/ prefix (local-images root = images/)
|
||
|
||
with open(image_path, "rb") as img_f:
|
||
img_content = b64.b64encode(img_f.read()).decode()
|
||
|
||
img_resp = requests.post(
|
||
"https://www.brettspiel-news.de/api/index.php/v1/media/files",
|
||
headers={
|
||
"X-Joomla-Token": JOOMLA_TOKEN,
|
||
"Accept": "application/vnd.api+json",
|
||
"Content-Type": "application/json",
|
||
},
|
||
json={"path": j_img_path, "content": img_content},
|
||
timeout=30
|
||
)
|
||
|
||
if img_resp.status_code == 400 and "exists" in img_resp.text:
|
||
# Überschreiben existierender Datei
|
||
print(f"[Image] {img_filename} existiert — überschreibe via PATCH", flush=True)
|
||
img_resp = requests.patch(
|
||
f"https://www.brettspiel-news.de/api/index.php/v1/media/files/local-images:/{j_img_path}",
|
||
headers={
|
||
"X-Joomla-Token": JOOMLA_TOKEN,
|
||
"Accept": "application/vnd.api+json",
|
||
"Content-Type": "application/json",
|
||
},
|
||
json={"path": j_img_path, "content": img_content},
|
||
timeout=30
|
||
)
|
||
|
||
if img_resp.status_code in (200, 201):
|
||
j_img_url = f"https://www.brettspiel-news.de/images/{j_img_path}" # Web-URL braucht images/ Prefix
|
||
# Joomla requires relative + joomlaImage pseudo-protocol
|
||
# Format: images/Spiele/2026/file.jpg#joomlaImage://local-images/Spiele/2026/file.jpg
|
||
# image_intro braucht images/ prefix, joomlaImage NICHT
|
||
# ── Bild-Dimensionen erkennen (Joomla 6 braucht ?width=&height=) ──
|
||
try:
|
||
from PIL import Image
|
||
img_pil = Image.open(image_path)
|
||
iw, ih = img_pil.size
|
||
img_pil.close()
|
||
except:
|
||
iw, ih = 1920, 1080 # Fallback: Standard-BSN-Bildmaße
|
||
image_intro_value = f"images/{j_img_path}#joomlaImage://local-images/{j_img_path}?width={iw}&height={ih}"
|
||
article_payload["images"] = {
|
||
"image_intro": image_intro_value,
|
||
"image_intro_alt": title,
|
||
"image_fulltext": image_intro_value,
|
||
"image_fulltext_alt": title,
|
||
}
|
||
print(f"[Image] Uploaded {img_filename} → {j_img_path}", flush=True)
|
||
else:
|
||
print(f"[Image] Upload failed HTTP {img_resp.status_code}: {img_resp.text[:200]}", flush=True)
|
||
except Exception as e:
|
||
print(f"[Image] Upload error: {e}", flush=True)
|
||
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:
|
||
published_ids.append(new_jid)
|
||
|
||
if new_jid:
|
||
# Immer Joomla-ID in die HTML-Datei schreiben (auch bei Updates)
|
||
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="([^"]*)"',
|
||
f'<meta name="joomla-id" content="{new_jid}"',
|
||
html_content
|
||
)
|
||
with open(filepath, "w", encoding="utf-8") as f:
|
||
f.write(html_content)
|
||
|
||
# ── Image mapping cleanup: remove association but keep files ──
|
||
# Daniel 19.06.: Bilder NICHT löschen — sie werden für
|
||
# spätere Live-Schaltungen und Referenz benötigt.
|
||
if fname in img_map:
|
||
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,
|
||
"error": None if published > 0 else (errors[0] if errors else "Unbekannter Fehler beim Veröffentlichen"),
|
||
"published": published,
|
||
"published_ids": published_ids,
|
||
"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
|
||
})
|
||
|
||
# ── Review Builder ──────────────────────────────────────────────────
|
||
|
||
_REVIEW_BUILDER_HTML = """<!DOCTYPE html>
|
||
<html lang="de"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||
<title>BSN Review Builder</title>
|
||
<style>
|
||
:root{{--bsn:#20228a;--bg:#F5F6FA;--card:#fff;--border:#E1E4E8;--text:#1F2937;--muted:#6B7280}}
|
||
*{{box-sizing:border-box;margin:0;padding:0}}
|
||
body{{font-family:system-ui,-apple-system,sans-serif;background:var(--bg);color:var(--text)}}
|
||
.container{{max-width:900px;margin:0 auto;padding:24px 16px}}
|
||
h1{{color:var(--bsn);font-size:1.6em;margin-bottom:8px}}
|
||
h2{{color:var(--bsn);font-size:1.2em;margin:24px 0 12px;padding-bottom:6px;border-bottom:2px solid var(--bsn)}}
|
||
.card{{background:var(--card);border-radius:12px;padding:20px;margin:12px 0;border:1px solid var(--border);box-shadow:0 1px 3px rgba(0,0,0,.04)}}
|
||
label{{display:block;font-weight:600;margin:12px 0 4px;color:var(--text);font-size:.9em}}
|
||
input[type=text],input[type=number],select,textarea{{width:100%;padding:10px 14px;border:2px solid var(--border);border-radius:8px;font-size:1em;font-family:inherit;background:#fff;transition:border-color .2s}}
|
||
input:focus,select:focus,textarea:focus{{border-color:var(--bsn);outline:none}}
|
||
textarea{{min-height:100px;resize:vertical}}
|
||
.drop-zone{{border:2px dashed var(--border);border-radius:12px;padding:32px;text-align:center;cursor:pointer;transition:all .2s;background:#FAFBFC;margin:8px 0}}
|
||
.drop-zone:hover,.drop-zone.dragover{{border-color:var(--bsn);background:#EEF0FF}}
|
||
.drop-zone-label{{color:var(--muted);font-size:.95em}}
|
||
.drop-zone-label strong{{color:var(--bsn)}}
|
||
.file-list{{list-style:none;margin-top:8px}}
|
||
.file-list li{{padding:6px 12px;background:#E8F5E9;border-radius:6px;margin:4px 0;font-size:.9em;display:flex;align-items:center;gap:8px}}
|
||
.file-list li .remove{{cursor:pointer;color:#EF4444;font-weight:bold;margin-left:auto}}
|
||
.btn{{display:inline-block;padding:12px 32px;border:none;border-radius:8px;font-size:1em;font-weight:600;cursor:pointer;transition:all .2s}}
|
||
.btn-primary{{background:var(--bsn);color:#fff}}
|
||
.btn-primary:hover{{opacity:.9}}
|
||
.btn-outline{{background:#fff;color:var(--bsn);border:2px solid var(--bsn)}}
|
||
.btn-outline:hover{{background:#EEF0FF}}
|
||
.btn-group{{display:flex;gap:12px;flex-wrap:wrap;margin:20px 0}}
|
||
.toast{{position:fixed;bottom:24px;left:50%;transform:translateX(-50%);padding:12px 24px;border-radius:8px;color:#fff;font-weight:600;z-index:9999;opacity:0;transition:opacity .3s}}
|
||
.toast.show{{opacity:1}}
|
||
.toast.ok{{background:#10B981}}
|
||
.toast.err{{background:#EF4444}}
|
||
.toast.info{{background:var(--bsn)}}
|
||
.result-box{{background:#F0FDF4;border:2px solid #10B981;border-radius:12px;padding:20px;margin:16px 0;display:none}}
|
||
.result-box.visible{{display:block}}
|
||
.result-box a{{color:var(--bsn);font-weight:600}}
|
||
.preview-frame{{width:100%;height:500px;border:2px solid var(--border);border-radius:8px;margin:12px 0}}
|
||
.back-link{{color:var(--muted);text-decoration:none;font-size:.9em}}
|
||
.back-link:hover{{color:var(--bsn)}}
|
||
.row-2{{display:grid;grid-template-columns:1fr 1fr;gap:16px}}
|
||
@media(max-width:600px){{.row-2{{grid-template-columns:1fr}}}}
|
||
</style></head><body>
|
||
<div class="container">
|
||
<a href="/?key={access_token}" class="back-link">Zuruck zur Artikel-Ubersicht</a>
|
||
<h1>Review Builder</h1>
|
||
<p style="color:var(--muted)">Word-Datei + Bilder hochladen. Inhalte werden automatisch zugeordnet.</p>
|
||
<form id="reviewForm" enctype="multipart/form-data">
|
||
<div class="card"><h2>1. Dateien hochladen</h2>
|
||
<div class="row-2">
|
||
<div>
|
||
<div class="drop-zone" id="excelDrop" style="padding:20px">
|
||
<div class="drop-zone-label"><strong>Excel mit Fakten</strong><br>(.xlsx)</div>
|
||
<input type="file" id="excelInput" accept=".xlsx" style="display:none">
|
||
</div><ul class="file-list" id="excelList"></ul>
|
||
</div>
|
||
<div>
|
||
<div class="drop-zone" id="wordDrop" style="padding:20px">
|
||
<div class="drop-zone-label"><strong>Word mit Artikeltext</strong><br>(.docx)</div>
|
||
<input type="file" id="wordInput" accept=".docx" style="display:none">
|
||
</div><ul class="file-list" id="wordList"></ul>
|
||
</div>
|
||
</div></div>
|
||
<div class="card"><h2>2. Bilder hochladen</h2>
|
||
<div class="drop-zone" id="imageDrop">
|
||
<div class="drop-zone-label"><strong>Bilder hier ablegen</strong> oder klicken</div>
|
||
<input type="file" id="imageInput" accept="image/*" multiple style="display:none">
|
||
</div><ul class="file-list" id="imageList"></ul>
|
||
<p style="font-size:.8em;color:var(--muted);margin-top:6px">Auto-Resize: 1920x1080 @ 72dpi</p></div>
|
||
<div class="card"><h2>3. Metadaten</h2>
|
||
<div class="row-2">
|
||
<div><label>Spielname (aus Excel)</label><input type="text" name="spielname" id="spielname" placeholder="Automatisch aus Excel"></div>
|
||
<div><label>Autor *</label><select name="autor" id="autor" required><option value="">Autor wahlen...</option>{author_opts}</select></div>
|
||
</div>
|
||
<div class="row-2">
|
||
<div><label>Rubrik *</label><select name="rubrik" id="rubrik" required><option value="">Rubrik wahlen...</option>{rubric_opts}</select></div>
|
||
<div><label>Komplexitat</label><select name="komplexitaet" id="komplexitaet"><option value="">(optional)</option>{komplex_opts}</select></div>
|
||
</div>
|
||
<div class="row-2">
|
||
<div><label>Herkunft *</label><select name="disclosure" id="disclosure" required>{disclosure_opts}</select></div>
|
||
<div><label>Verlag</label><input type="text" name="verlag" id="verlag"></div>
|
||
</div>
|
||
<div class="row-2">
|
||
<div><label>Fantasywelt URL</label><input type="text" name="fantasywelt_url" id="fantasywelt_url"></div>
|
||
<div><label>Spiele-Offensive URL</label><input type="text" name="so_url" id="so_url"></div>
|
||
</div>
|
||
<div class="row-2">
|
||
<div><label>Amazon URL</label><input type="text" name="amazon_url" id="amazon_url"></div>
|
||
<div><label>AllGames4You URL</label><input type="text" name="allgames4you_url" id="allgames4you_url"></div>
|
||
</div></div>
|
||
<div class="btn-group">
|
||
<button type="button" class="btn btn-outline" id="previewBtn">Vorschau</button>
|
||
<button type="button" class="btn btn-primary" id="publishBtn">Veroffentlichen</button>
|
||
</div></form>
|
||
<div class="result-box" id="resultBox"><h3 id="resultTitle" style="color:#065F46"></h3><p id="resultLink"></p></div>
|
||
</div>
|
||
<div class="toast" id="toast"></div>
|
||
<script>
|
||
const excelDrop=document.getElementById('excelDrop'),excelInput=document.getElementById('excelInput'),excelList=document.getElementById('excelList');
|
||
const wordDrop=document.getElementById('wordDrop'),wordInput=document.getElementById('wordInput'),wordList=document.getElementById('wordList');
|
||
const imageDrop=document.getElementById('imageDrop'),imageInput=document.getElementById('imageInput'),imageList=document.getElementById('imageList');
|
||
let excelFile=null,wordFile=null,imageFiles=[];
|
||
function st(msg,type){{var t=document.getElementById('toast');t.textContent=msg;t.className='toast '+type+' show';clearTimeout(t._t);t._t=setTimeout(function(){{t.classList.remove('show')}},3000)}}
|
||
excelDrop.addEventListener('click',function(){{excelInput.click()}});
|
||
excelDrop.addEventListener('dragover',function(e){{e.preventDefault();excelDrop.classList.add('dragover')}});
|
||
excelDrop.addEventListener('dragleave',function(){{excelDrop.classList.remove('dragover')}});
|
||
excelDrop.addEventListener('drop',function(e){{e.preventDefault();excelDrop.classList.remove('dragover');if(e.dataTransfer.files[0])aef(e.dataTransfer.files[0])}});
|
||
excelInput.addEventListener('change',function(){{if(excelInput.files[0])aef(excelInput.files[0])}});
|
||
function aef(f){{excelFile=f;excelList.innerHTML='<li>'+f.name+' ('+(f.size/1024).toFixed(1)+' KB) <span class="remove" onclick="excelFile=null;excelList.innerHTML=\\'\\';">X</span></li>'}}
|
||
wordDrop.addEventListener('click',function(){{wordInput.click()}});
|
||
wordDrop.addEventListener('dragover',function(e){{e.preventDefault();wordDrop.classList.add('dragover')}});
|
||
wordDrop.addEventListener('dragleave',function(){{wordDrop.classList.remove('dragover')}});
|
||
wordDrop.addEventListener('drop',function(e){{e.preventDefault();wordDrop.classList.remove('dragover');if(e.dataTransfer.files[0])awf(e.dataTransfer.files[0])}});
|
||
wordInput.addEventListener('change',function(){{if(wordInput.files[0])awf(wordInput.files[0])}});
|
||
function awf(f){{wordFile=f;wordList.innerHTML='<li>'+f.name+' ('+(f.size/1024).toFixed(1)+' KB) <span class="remove" onclick="wordFile=null;wordList.innerHTML=\\'\\';">X</span></li>'}}
|
||
imageDrop.addEventListener('click',function(){{imageInput.click()}});
|
||
imageDrop.addEventListener('dragover',function(e){{e.preventDefault();imageDrop.classList.add('dragover')}});
|
||
imageDrop.addEventListener('dragleave',function(){{imageDrop.classList.remove('dragover')}});
|
||
imageDrop.addEventListener('drop',function(e){{e.preventDefault();imageDrop.classList.remove('dragover');aif(e.dataTransfer.files)}});
|
||
imageInput.addEventListener('change',function(){{aif(imageInput.files)}});
|
||
function aif(files){{for(var i=0;i<files.length;i++){{(function(f){{imageFiles.push(f);var li=document.createElement('li');li.innerHTML=f.name+' ('+(f.size/1024).toFixed(1)+' KB) <span class="remove">X</span>';li.querySelector('.remove').onclick=function(){{imageFiles=imageFiles.filter(function(x){{return x!==f}});li.remove()}};imageList.appendChild(li)}})(files[i])}}}}
|
||
async function submitForm(publish){{var s=document.getElementById('spielname').value.trim();var fd=new FormData();if(excelFile)fd.append('excel',excelFile);if(wordFile)fd.append('word',wordFile);imageFiles.forEach(function(f){{fd.append('images',f)}});fd.append('spielname',s);fd.append('autor',document.getElementById('autor').value);fd.append('rubrik',document.getElementById('rubrik').value);fd.append('komplexitaet',document.getElementById('komplexitaet').value);fd.append('disclosure',document.getElementById('disclosure').value);fd.append('verlag',document.getElementById('verlag').value.trim());fd.append('fantasywelt_url',document.getElementById('fantasywelt_url').value.trim());fd.append('so_url',document.getElementById('so_url').value.trim());fd.append('amazon_url',document.getElementById('amazon_url').value.trim());fd.append('allgames4you_url',document.getElementById('allgames4you_url').value.trim());fd.append('publish',publish?'1':'0');st(publish?'Veroffentliche...':'Baue Vorschau...','info');try{{var resp=await fetch('/review-builder/build?key={access_token}',{{method:'POST',body:fd}});var data=await resp.json();if(!data.ok){{st(data.error||'Fehler','err');return}}var box=document.getElementById('resultBox');box.classList.add('visible');document.getElementById('resultTitle').textContent=data.title;document.getElementById('resultLink').innerHTML=data.published?'<a href="'+data.article_url+'" target="_blank">Artikel online: '+data.article_url+'</a>':'<a href="/'+data.filename+'?key={access_token}" target="_blank">Vorschau: '+data.filename+'</a>';st(data.published?'Veroffentlicht!':'Vorschau bereit','ok')}}catch(e){{st('Fehler: '+e.message,'err')}}}}
|
||
document.getElementById('previewBtn').addEventListener('click',function(){{submitForm(false)}});
|
||
document.getElementById('publishBtn').addEventListener('click',function(){{submitForm(true)}});
|
||
</script></body></html>"""
|
||
|
||
def _build_review_builder_form(self):
|
||
"""Render the Review Builder upload form."""
|
||
cfg = REVIEW_CONFIG
|
||
authors = cfg.get("authors", {})
|
||
author_opts = "".join(f'<option value="{k}">{v["name"]}</option>' for k, v in authors.items())
|
||
rubrics = cfg.get("rubrics", [])
|
||
rubric_opts = "".join(f'<option value="{r["id"]}">{r["name"]}</option>' for r in rubrics)
|
||
disclosures = cfg.get("disclosures", [])
|
||
disclosure_opts = "".join(f'<option value="{d["id"]}">{d["text"]}</option>' for d in disclosures)
|
||
komplex_opts = "".join(f'<option value="{k}">{k}</option>' for k in cfg.get("komplexitaet", []))
|
||
return self._REVIEW_BUILDER_HTML.format(
|
||
access_token=ACCESS_TOKEN,
|
||
author_opts=author_opts,
|
||
rubric_opts=rubric_opts,
|
||
disclosure_opts=disclosure_opts,
|
||
komplex_opts=komplex_opts,
|
||
)
|
||
|
||
def _handle_review_builder_build(self):
|
||
"""POST: Excel parsen, HTML bauen."""
|
||
ctype = self.headers.get("Content-Type", "")
|
||
if "multipart/form-data" not in ctype:
|
||
self._json_reply(400, {"ok": False, "error": "Nur multipart/form-data"})
|
||
return
|
||
clen = int(self.headers.get("Content-Length", "0"))
|
||
raw = self.rfile.read(clen)
|
||
boundary = ctype.split("boundary=")[1].strip()
|
||
if boundary.startswith('"') and boundary.endswith('"'):
|
||
boundary = boundary[1:-1]
|
||
parts = _parse_multipart(raw, boundary)
|
||
excel_data = parts.get("excel")
|
||
word_data = parts.get("word")
|
||
image_datas = [v for k, v in parts.items() if k.startswith("images_")]
|
||
meta = {k: v for k, v in parts.items() if k not in ("excel", "word") and not k.startswith("images_")}
|
||
|
||
# Excel parsen (Fakten, Wertung, Pro/Kontra, Tags)
|
||
xl = {}
|
||
if excel_data:
|
||
try:
|
||
import openpyxl
|
||
wb = openpyxl.load_workbook(io.BytesIO(excel_data), data_only=True)
|
||
ws = wb["EINGABE"]
|
||
for row in ws.iter_rows(min_row=1, values_only=True):
|
||
key = str(row[0]).strip() if row[0] else ""
|
||
val = str(row[1]).strip() if row[1] else ""
|
||
if key and val:
|
||
xl[key] = val
|
||
except Exception as e:
|
||
self._json_reply(400, {"ok": False, "error": f"Excel-Fehler: {e}"})
|
||
return
|
||
|
||
# Word parsen (Artikeltext)
|
||
word_text = ""
|
||
if word_data:
|
||
try:
|
||
from docx import Document
|
||
doc = Document(io.BytesIO(word_data))
|
||
word_text = "\n".join(p.text for p in doc.paragraphs if p.text.strip())
|
||
except Exception as e:
|
||
self._json_reply(400, {"ok": False, "error": f"Word-Fehler: {e}"})
|
||
return
|
||
|
||
spielname = xl.get("Spieltitel", meta.get("spielname", "Unbekannt")).strip()
|
||
zusatz = xl.get("Zusatztitel", "").strip()
|
||
tester = xl.get("Test von", "").strip()
|
||
|
||
# Autor-Mapping: "Jan Maguna" → autor_key (immer aus Excel, Form nur Fallback)
|
||
autor_key = meta.get("autor", "basti").strip()
|
||
cfg = REVIEW_CONFIG
|
||
if tester:
|
||
for k, v in cfg.get("authors", {}).items():
|
||
if v["name"].lower() == tester.lower():
|
||
autor_key = k
|
||
break
|
||
author_info = cfg.get("authors", {}).get(autor_key, {"name": tester or autor_key, "banner": "sebastian_meine_meinung_uberschrift.png"})
|
||
|
||
rubrik_id = int(meta.get("rubrik", "8"))
|
||
do_publish = meta.get("publish", "0") == "1"
|
||
fantasywelt_url = meta.get("fantasywelt_url", "").strip()
|
||
so_url = meta.get("so_url", "").strip()
|
||
amazon_url = meta.get("amazon_url", "").strip()
|
||
allgames4you_url = meta.get("allgames4you_url", "").strip()
|
||
|
||
# Excel-Felder
|
||
komplex = xl.get("Komplexität des Spiels", "").strip()
|
||
autoren = xl.get("Autor:innen", "").strip()
|
||
illustratoren = xl.get("Illustrator:innen", "").strip()
|
||
verlag = xl.get("Verlag/Vertrieb", "").strip()
|
||
personen = xl.get("Personenzahl", "").strip()
|
||
spielzeit = xl.get("Spielzeit", "").strip()
|
||
jahr = xl.get("Erscheinungsjahr", "").strip()
|
||
preis = xl.get("UVP/Ø-Preis", "").strip()
|
||
app = xl.get("App-Unterstützung", "").strip()
|
||
alter = xl.get("empfohlenes Alter", "").strip()
|
||
wertung = xl.get("WERTUNG (1-100)", "").strip()
|
||
sprache = xl.get("Sprache", "Deutsch").strip()
|
||
|
||
# Mechanismen
|
||
mech = []
|
||
for i in range(1, 5):
|
||
m = xl.get(f"Mechanismus {i}", "").strip()
|
||
if m: mech.append(m)
|
||
|
||
# Pro/Kontra
|
||
pros = []
|
||
for i in range(1, 5):
|
||
p = xl.get(f"Pro {i}", "").strip()
|
||
if p: pros.append(p)
|
||
kontras = []
|
||
for i in range(1, 5):
|
||
k = xl.get(f"Kontra {i}", "").strip()
|
||
if k: kontras.append(k)
|
||
|
||
# Tags
|
||
tags = []
|
||
for i in range(1, 11):
|
||
t = xl.get(f"Tag {i}", "").strip()
|
||
if t: tags.append(t)
|
||
|
||
# Disclosure aus Verlag
|
||
verlag_name = meta.get("verlag", verlag).strip()
|
||
disclosure_id = meta.get("disclosure", "verlag_gestellt").strip()
|
||
disclosure_text = ""
|
||
for d in cfg.get("disclosures", []):
|
||
if d["id"] == disclosure_id:
|
||
disclosure_text = d["text"].replace("{VERLAG}", verlag_name or "der Verlag").replace("{SPIEL}", spielname)
|
||
break
|
||
|
||
# Bilder speichern
|
||
erster = spielname[0].lower() if spielname else "x"
|
||
ordner = spielname.lower().replace(" ", "")
|
||
base_img = f"images/Bilderverzeichnis/01/{erster}/{ordner}"
|
||
wd = os.path.join(WORKSPACE, base_img, "wertung")
|
||
os.makedirs(wd, exist_ok=True)
|
||
for idx, img_data in enumerate(image_datas):
|
||
try:
|
||
_resize_image(img_data, os.path.join(wd, f"bild_{idx+1}.jpg"))
|
||
except Exception as e:
|
||
print(f"[review-builder] Bild {idx} fehlgeschlagen: {e}", flush=True)
|
||
|
||
# HTML bauen
|
||
datum = datetime.now().strftime("%d. %B %Y")
|
||
autor_name = author_info["name"]
|
||
autor_banner = author_info["banner"]
|
||
|
||
# Infobox-Tabelle
|
||
fakten_rows = []
|
||
if autoren: fakten_rows.append(f'<tr><td><strong>Autor:innen</strong></td><td>{autoren}</td></tr>')
|
||
if illustratoren: fakten_rows.append(f'<tr><td><strong>Illustrator:innen</strong></td><td>{illustratoren}</td></tr>')
|
||
if verlag: fakten_rows.append(f'<tr><td><strong>Verlag</strong></td><td>{verlag}</td></tr>')
|
||
if personen: fakten_rows.append(f'<tr><td><strong>Personenzahl</strong></td><td>{personen}</td></tr>')
|
||
if spielzeit: fakten_rows.append(f'<tr><td><strong>Spielzeit</strong></td><td>{spielzeit}</td></tr>')
|
||
if jahr: fakten_rows.append(f'<tr><td><strong>Erscheinungsjahr</strong></td><td>{jahr}</td></tr>')
|
||
if preis: fakten_rows.append(f'<tr><td><strong>Preis</strong></td><td>{preis}</td></tr>')
|
||
if app: fakten_rows.append(f'<tr><td><strong>App</strong></td><td>{app}</td></tr>')
|
||
if alter: fakten_rows.append(f'<tr><td><strong>Alter</strong></td><td>{alter}</td></tr>')
|
||
fakten_html = ""
|
||
if fakten_rows:
|
||
fakten_html = '<table class="bsn-table"><tbody>' + "".join(fakten_rows) + '</tbody></table>'
|
||
|
||
# Pro/Kontra
|
||
pro_html = ""
|
||
if pros:
|
||
pro_html = '<div><h3>👍 PRO</h3><ul>' + "".join(f"<li>{p}</li>" for p in pros) + '</ul></div>'
|
||
kontra_html = ""
|
||
if kontras:
|
||
kontra_html = '<div><h3>👎 KONTRA</h3><ul>' + "".join(f"<li>{k}</li>" for k in kontras) + '</ul></div>'
|
||
|
||
# Mechanismen
|
||
mech_html = ""
|
||
if mech:
|
||
mech_html = '<p><strong>Mechanismen:</strong> ' + ", ".join(mech) + '</p>'
|
||
|
||
# Bewertung
|
||
wertung_html = ""
|
||
if wertung:
|
||
wertung_html = f'<div class="bsn-rating"><span class="bsn-rating-score">{wertung}</span><span class="bsn-rating-label">/ 100</span></div>'
|
||
|
||
disc_html = ""
|
||
if disclosure_text:
|
||
disc_html = f'<p style="text-align:center;"><em>{disclosure_text}<br>Dies hat keinen Einfluss auf unsere Bewertung!</em></p>'
|
||
|
||
aff_html = ""
|
||
if fantasywelt_url:
|
||
aff_html += f'<p><a href="{fantasywelt_url}" target="_blank" rel="noopener"><img style="margin:10px auto;display:block;" src="images/banners/fantasywelt/banner_fantasywelt-jetzt-kaufen.jpg" alt="Bei Fantasywelt kaufen"></a></p>\n'
|
||
if so_url:
|
||
aff_html += f'<p><a href="{so_url}" target="_blank" rel="noopener"><img style="margin:10px auto;display:block;" src="images/banners/fantasywelt/SO_kaufen_button.jpg" alt="Bei Spiele-Offensive kaufen"></a></p>\n'
|
||
if amazon_url:
|
||
aff_html += f'<p><a href="{amazon_url}" target="_blank" rel="noopener"><img style="margin:10px auto;display:block;" src="images/banners/amazon/amazon-kaufen-button.jpg" alt="Bei Amazon kaufen"></a></p>\n'
|
||
if allgames4you_url:
|
||
aff_html += f'<p><a href="{allgames4you_url}" target="_blank" rel="noopener"><img style="margin:10px auto;display:block;" src="images/banners/allgames4you/allgames4you-kaufen-button.jpg" alt="Bei AllGames4You kaufen"></a></p>\n'
|
||
|
||
gal_path = f"01/{erster}/{ordner}"
|
||
tags_str = ", ".join(tags) if tags else f"{spielname}, Brettspiel, Review, Bewertung, {autor_name}"
|
||
title_full = f"{spielname} {zusatz}".strip()
|
||
|
||
# Artikeltext aus Word (zwischen Infobox und Fazit)
|
||
artikel_html = ""
|
||
if word_text:
|
||
if "<" not in word_text[:100]:
|
||
artikel_html = f"<p>{word_text.replace(chr(10)+chr(10), '</p><p>')}</p>"
|
||
else:
|
||
artikel_html = word_text
|
||
|
||
body = f"""<p>{spielname} — {komplex or 'Brettspiel'} von {autoren or verlag or 'unbekannt'}</p>
|
||
<hr id="system-readmore">
|
||
<p> </p>
|
||
<p><img style="margin:10px auto;display:block;" src="{base_img}/wertung/info.png" alt="Fakten zum Spiel"></p>
|
||
{fakten_html}
|
||
{disc_html}
|
||
{mech_html}
|
||
{artikel_html}
|
||
{pro_html}
|
||
{kontra_html}
|
||
<h2><img src="images/banners/redaktion/{autor_banner}" alt="Meine Meinung"></h2>
|
||
{wertung_html}
|
||
<p> </p>
|
||
<p style="text-align:center;">{{{{module Discord-Hinweis Tests}}}}</p>
|
||
<p> </p>
|
||
{aff_html}
|
||
<p> </p>
|
||
<h2>Bilder vom Spiel</h2>
|
||
<p>{{{{gallery}}}}{gal_path}{{{{/gallery}}}}</p>
|
||
<p>{{{{module Aktuelle Texte fur Content}}}}</p>"""
|
||
|
||
datei = datetime.now().strftime("%Y-%m-%d")
|
||
safe = spielname.lower().replace(" ", "-").replace(":", "").replace("/", "-")
|
||
filename = f"artikel_{safe}_{datei}.html"
|
||
filepath = os.path.join(WORKSPACE, filename)
|
||
full = f"""<!DOCTYPE html>
|
||
<html lang="de"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||
<meta name="description" content="Brettspiel-Review: {title_full}">
|
||
<meta name="keywords" content="{tags_str}">
|
||
<meta name="category-id" content="{rubrik_id}">
|
||
<title>{title_full} — Review von {autor_name} | Brettspiel-News.de</title></head>
|
||
<body><article><p class="meta">{datum} | von {autor_name}</p>{body}</article></body></html>"""
|
||
with open(filepath, "w", encoding="utf-8") as f:
|
||
f.write(full)
|
||
result = {"ok": True, "title": f"Review '{spielname}' erstellt", "filename": filename,
|
||
"published": False, "article_url": "", "preview_url": f"/{filename}?key={ACCESS_TOKEN}"}
|
||
if do_publish and JOOMLA_TOKEN:
|
||
try:
|
||
pub = _publish_to_joomla(filepath, rubrik_id)
|
||
if pub.get("ok"):
|
||
result["published"] = True
|
||
result["article_url"] = f"https://www.brettspiel-news.de/index.php/de/?view=article&id={pub.get('id','')}"
|
||
else:
|
||
result["title"] = f"Gespeichert, Publish fehlgeschlagen: {pub.get('error','')}"
|
||
except Exception as e:
|
||
result["title"] = f"Gespeichert, Publish fehlgeschlagen: {e}"
|
||
self._json_reply(200, result)
|
||
|
||
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"))
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════
|
||
# NanoBanana Editor HTML
|
||
# ═══════════════════════════════════════════════════════════════════════════
|
||
|
||
NANOBANANA_HTML = """<!DOCTYPE html>
|
||
<html lang="de">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>BSN Bild-Generator — Google Imagen</title>
|
||
<style>
|
||
:root {{
|
||
--bsn-blue: #20228a;
|
||
--bsn-blue-light: #2d30b0;
|
||
--bg: #f5f6fa;
|
||
--card-bg: #fff;
|
||
--text: #1a1a2e;
|
||
--text-muted: #666;
|
||
--radius: 12px;
|
||
--shadow: 0 2px 12px rgba(0,0,0,0.08);
|
||
}}
|
||
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
|
||
body {{
|
||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||
background: var(--bg);
|
||
color: var(--text);
|
||
min-height: 100vh;
|
||
}}
|
||
.header {{
|
||
background: var(--bsn-blue);
|
||
color: #fff;
|
||
padding: 16px 24px;
|
||
font-size: 1.3em;
|
||
font-weight: 700;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
}}
|
||
.header a {{
|
||
color: #fff;
|
||
font-size: 0.7em;
|
||
font-weight: 400;
|
||
opacity: 0.8;
|
||
text-decoration: none;
|
||
margin-left: auto;
|
||
}}
|
||
.container {{
|
||
max-width: 900px;
|
||
margin: 0 auto;
|
||
padding: 20px;
|
||
}}
|
||
.card {{
|
||
background: var(--card-bg);
|
||
border-radius: var(--radius);
|
||
box-shadow: var(--shadow);
|
||
padding: 24px;
|
||
margin-bottom: 20px;
|
||
}}
|
||
.card h2 {{
|
||
font-size: 1.1em;
|
||
margin-bottom: 16px;
|
||
color: var(--bsn-blue);
|
||
}}
|
||
/* Upload-Zone */
|
||
.dropzone {{
|
||
border: 2px dashed #ccc;
|
||
border-radius: var(--radius);
|
||
padding: 40px 20px;
|
||
text-align: center;
|
||
cursor: pointer;
|
||
transition: all 0.3s;
|
||
background: #fafbfc;
|
||
min-height: 120px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 8px;
|
||
}}
|
||
.dropzone:hover, .dropzone.drag-over {{
|
||
border-color: var(--bsn-blue);
|
||
background: #eef0ff;
|
||
}}
|
||
.dropzone .icon {{ font-size: 2.5em; }}
|
||
.dropzone .hint {{ color: var(--text-muted); font-size: 0.9em; }}
|
||
.dropzone input[type=file] {{ display: none; }}
|
||
.preview-box {{
|
||
position: relative;
|
||
display: inline-block;
|
||
margin-top: 12px;
|
||
}}
|
||
.preview-box img {{
|
||
max-height: 200px;
|
||
border-radius: 8px;
|
||
box-shadow: var(--shadow);
|
||
}}
|
||
.preview-box .remove-btn {{
|
||
position: absolute;
|
||
top: -8px;
|
||
right: -8px;
|
||
background: #e74c3c;
|
||
color: #fff;
|
||
border: none;
|
||
width: 28px;
|
||
height: 28px;
|
||
border-radius: 50%;
|
||
cursor: pointer;
|
||
font-size: 1.1em;
|
||
line-height: 1;
|
||
}}
|
||
/* Form */
|
||
.form-row {{
|
||
display: flex;
|
||
gap: 12px;
|
||
margin-bottom: 12px;
|
||
flex-wrap: wrap;
|
||
}}
|
||
.form-row label {{
|
||
font-weight: 600;
|
||
font-size: 0.85em;
|
||
color: var(--text-muted);
|
||
display: block;
|
||
margin-bottom: 4px;
|
||
}}
|
||
.form-row > div {{ flex: 1; min-width: 140px; }}
|
||
textarea, select, button {{
|
||
font-family: inherit;
|
||
font-size: 1em;
|
||
border-radius: 8px;
|
||
border: 1px solid #ddd;
|
||
padding: 10px 14px;
|
||
width: 100%;
|
||
background: #fff;
|
||
color: var(--text);
|
||
}}
|
||
textarea {{
|
||
min-height: 100px;
|
||
resize: vertical;
|
||
line-height: 1.5;
|
||
}}
|
||
textarea:focus, select:focus {{ border-color: var(--bsn-blue); outline: none; box-shadow: 0 0 0 3px rgba(32,34,138,0.1); }}
|
||
.char-count {{ text-align: right; font-size: 0.8em; color: var(--text-muted); margin-top: 4px; }}
|
||
/* Buttons */
|
||
.btn {{
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 8px;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
border: none;
|
||
border-radius: 8px;
|
||
padding: 12px 24px;
|
||
font-size: 1em;
|
||
transition: all 0.2s;
|
||
min-height: 48px;
|
||
}}
|
||
.btn-primary {{
|
||
background: var(--bsn-blue);
|
||
color: #fff;
|
||
}}
|
||
.btn-primary:hover {{ background: var(--bsn-blue-light); }}
|
||
.btn-primary:disabled {{
|
||
background: #999;
|
||
cursor: not-allowed;
|
||
}}
|
||
.btn-secondary {{
|
||
background: #e0e0e0;
|
||
color: var(--text);
|
||
}}
|
||
.btn-download {{
|
||
background: #27ae60;
|
||
color: #fff;
|
||
}}
|
||
.btn-download:hover {{ background: #219a52; }}
|
||
/* Ergebnis */
|
||
.result-box {{
|
||
display: none;
|
||
}}
|
||
.result-box.visible {{
|
||
display: block;
|
||
}}
|
||
.result-box img {{
|
||
width: 100%;
|
||
max-height: 500px;
|
||
object-fit: contain;
|
||
border-radius: var(--radius);
|
||
box-shadow: var(--shadow);
|
||
margin-bottom: 16px;
|
||
}}
|
||
.result-actions {{
|
||
display: flex;
|
||
gap: 12px;
|
||
flex-wrap: wrap;
|
||
}}
|
||
/* Spinner */
|
||
.spinner {{
|
||
display: none;
|
||
text-align: center;
|
||
padding: 40px 20px;
|
||
}}
|
||
.spinner.active {{
|
||
display: block;
|
||
}}
|
||
.spinner-icon {{
|
||
width: 48px;
|
||
height: 48px;
|
||
border: 4px solid #e0e0e0;
|
||
border-top-color: var(--bsn-blue);
|
||
border-radius: 50%;
|
||
animation: spin 0.8s linear infinite;
|
||
margin: 0 auto 16px;
|
||
}}
|
||
@keyframes spin {{ to {{ transform: rotate(360deg); }} }}
|
||
.spinner-text {{ color: var(--text-muted); font-size: 0.95em; }}
|
||
/* Toast */
|
||
.toast {{
|
||
position: fixed;
|
||
bottom: 20px;
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
background: #333;
|
||
color: #fff;
|
||
padding: 12px 24px;
|
||
border-radius: 8px;
|
||
font-size: 0.95em;
|
||
z-index: 1000;
|
||
opacity: 0;
|
||
transition: opacity 0.3s;
|
||
pointer-events: none;
|
||
}}
|
||
.toast.show {{ opacity: 1; }}
|
||
.toast.success {{ background: #27ae60; }}
|
||
.toast.error {{ background: #e74c3c; }}
|
||
/* Mobile */
|
||
@media (max-width: 600px) {{
|
||
.container {{ padding: 12px; }}
|
||
.card {{ padding: 16px; }}
|
||
.header {{ padding: 12px 16px; font-size: 1.1em; }}
|
||
.form-row {{ flex-direction: column; gap: 8px; }}
|
||
.result-actions {{ flex-direction: column; }}
|
||
.result-actions .btn {{ width: 100%; }}
|
||
}}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="header">
|
||
🎨 BSN Bild-Generator
|
||
<a href="/?key={key}">← Zurück zur Artikelübersicht</a>
|
||
</div>
|
||
|
||
<div class="container">
|
||
|
||
<!-- Foto-Upload -->
|
||
<div class="card">
|
||
<h2>📷 Referenzbild (optional)</h2>
|
||
<div class="dropzone" id="dropZone">
|
||
<span class="icon">🖼️</span>
|
||
<span class="hint">Foto hier ablegen oder klicken zum Auswählen</span>
|
||
<input type="file" id="fileInput" accept="image/*">
|
||
</div>
|
||
<div class="preview-box" id="previewBox" style="display:none">
|
||
<img id="previewImg" src="" alt="Vorschau">
|
||
<button class="remove-btn" id="removeBtn" title="Entfernen">✕</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Prompt -->
|
||
<div class="card">
|
||
<h2>✍️ Prompt</h2>
|
||
<textarea id="prompt" placeholder="Beschreibe das gewünschte Bild… Beispiele: • Professional board game photography of Catan, warm lighting, product shot • Transform this cover into a cinematic movie poster style, epic composition"></textarea>
|
||
<div class="char-count"><span id="charCount">0</span> Zeichen</div>
|
||
|
||
<div class="form-row" style="margin-top:12px">
|
||
<div>
|
||
<label>📐 Seitenverhältnis</label>
|
||
<select id="aspectRatio">
|
||
<option value="16:9">16:9 — Header (Standard)</option>
|
||
<option value="1:1">1:1 — Quadrat</option>
|
||
<option value="2:3">2:3 — Brettspiel-Box</option>
|
||
<option value="9:16">9:16 — Instagram Story</option>
|
||
<option value="21:9">21:9 — Breitformat</option>
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label>🔍 Auflösung</label>
|
||
<select id="resolution">
|
||
<option value="1K">1K — Schnell (geringere Qualität)</option>
|
||
<option value="2K">2K — 1920×1080 (Standard)</option>
|
||
<option value="4K">4K — Höchste Qualität</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Start Button -->
|
||
<button class="btn btn-primary" id="startBtn" style="width:100%; font-size:1.1em; padding:16px">
|
||
🚀 Bild generieren
|
||
</button>
|
||
|
||
<!-- Spinner -->
|
||
<div class="spinner" id="spinner">
|
||
<div class="spinner-icon"></div>
|
||
<div class="spinner-text" id="spinnerText">Generiere Bild…</div>
|
||
</div>
|
||
|
||
<!-- Ergebnis -->
|
||
<div class="result-box card" id="resultBox">
|
||
<h2>✅ Ergebnis</h2>
|
||
<img id="resultImg" src="" alt="Generiertes Bild">
|
||
<div class="result-actions">
|
||
<button class="btn btn-download" id="downloadBtn">⬇ Download</button>
|
||
<button class="btn btn-secondary" id="retryBtn">🔄 Neues Bild</button>
|
||
</div>
|
||
<div style="margin-top:16px">
|
||
<label style="font-weight:600;font-size:0.85em;color:var(--text-muted)">📄 Artikel zuordnen</label>
|
||
<div class="form-row" style="margin-top:4px">
|
||
<select id="articleSelect">
|
||
<option value="">-- Artikel wählen --</option>
|
||
{article_options}
|
||
</select>
|
||
<button class="btn btn-primary" id="assignBtn" style="flex:0 0 auto;min-width:auto">Zuordnen</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
|
||
<div class="toast" id="toast"></div>
|
||
|
||
<script>
|
||
const KEY = "{key}";
|
||
const BASE = window.location.origin;
|
||
let uploadedImageData = null; // File object
|
||
let currentResultUrl = null;
|
||
|
||
// ── Upload-Zone ──
|
||
const dropZone = document.getElementById('dropZone');
|
||
const fileInput = document.getElementById('fileInput');
|
||
const previewBox = document.getElementById('previewBox');
|
||
const previewImg = document.getElementById('previewImg');
|
||
const removeBtn = document.getElementById('removeBtn');
|
||
|
||
dropZone.addEventListener('click', () => fileInput.click());
|
||
fileInput.addEventListener('change', handleFile);
|
||
|
||
dropZone.addEventListener('dragover', (e) => {{ e.preventDefault(); dropZone.classList.add('drag-over'); }});
|
||
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('drag-over'));
|
||
dropZone.addEventListener('drop', (e) => {{
|
||
e.preventDefault();
|
||
dropZone.classList.remove('drag-over');
|
||
const files = e.dataTransfer.files;
|
||
if (files.length) handleFile({{ target: {{ files }} }});
|
||
}});
|
||
|
||
function handleFile(e) {{
|
||
const file = e.target.files[0];
|
||
if (!file) return;
|
||
uploadedImageData = file;
|
||
const reader = new FileReader();
|
||
reader.onload = (ev) => {{
|
||
previewImg.src = ev.target.result;
|
||
previewBox.style.display = 'inline-block';
|
||
dropZone.querySelector('.icon').textContent = '✅';
|
||
dropZone.querySelector('.hint').textContent = file.name + ' (' + (file.size/1024).toFixed(1) + ' KB)';
|
||
}};
|
||
reader.readAsDataURL(file);
|
||
}}
|
||
|
||
removeBtn.addEventListener('click', () => {{
|
||
uploadedImageData = null;
|
||
fileInput.value = '';
|
||
previewBox.style.display = 'none';
|
||
dropZone.querySelector('.icon').textContent = '🖼️';
|
||
dropZone.querySelector('.hint').textContent = 'Foto hier ablegen oder klicken zum Auswählen';
|
||
}});
|
||
|
||
// ── Char-Zähler ──
|
||
const promptEl = document.getElementById('prompt');
|
||
const charCount = document.getElementById('charCount');
|
||
promptEl.addEventListener('input', () => charCount.textContent = promptEl.value.length);
|
||
|
||
// ── Start ──
|
||
document.getElementById('startBtn').addEventListener('click', startGeneration);
|
||
|
||
async function startGeneration() {{
|
||
const prompt = promptEl.value.trim();
|
||
if (!prompt) {{
|
||
showToast('Bitte gib einen Prompt ein', 'error');
|
||
return;
|
||
}}
|
||
|
||
const btn = document.getElementById('startBtn');
|
||
btn.disabled = true;
|
||
btn.textContent = '⏳ Generiere…';
|
||
|
||
document.getElementById('resultBox').classList.remove('visible');
|
||
document.getElementById('spinner').classList.add('active');
|
||
document.getElementById('spinnerText').textContent = 'Google Imagen generiert…';
|
||
|
||
try {{
|
||
const formData = new FormData();
|
||
formData.append('prompt', prompt);
|
||
formData.append('aspectRatio', document.getElementById('aspectRatio').value);
|
||
formData.append('resolution', document.getElementById('resolution').value);
|
||
if (uploadedImageData) {{
|
||
formData.append('image', uploadedImageData);
|
||
}}
|
||
|
||
const resp = await fetch(BASE + '/nanobanana/generate?key=' + KEY, {{
|
||
method: 'POST',
|
||
body: formData
|
||
}});
|
||
|
||
let data;
|
||
try {{
|
||
data = await resp.json();
|
||
}} catch (e) {{
|
||
const text = await resp.text().catch(() => '');
|
||
throw new Error('Server-Antwort nicht lesbar (Status ' + resp.status + '): ' + text.substring(0, 200));
|
||
}}
|
||
|
||
if (!data.ok) {{
|
||
throw new Error(data.error || 'Unbekannter Fehler (Status ' + resp.status + ')');
|
||
}}
|
||
|
||
// DIREKT Ergebnis anzeigen — Google Imagen ist synchron!
|
||
document.getElementById('spinner').classList.remove('active');
|
||
btn.disabled = false;
|
||
btn.textContent = '🚀 Bild generieren';
|
||
|
||
currentResultUrl = data.imageUrl;
|
||
document.getElementById('resultImg').src = currentResultUrl;
|
||
document.getElementById('resultBox').classList.add('visible');
|
||
showToast('✅ Bild fertig!', 'success');
|
||
loadArticles();
|
||
|
||
}} catch (err) {{
|
||
document.getElementById('spinner').classList.remove('active');
|
||
btn.disabled = false;
|
||
btn.textContent = '🚀 Bild generieren';
|
||
showToast('❌ Fehler: ' + err.message, 'error');
|
||
}}
|
||
}}
|
||
|
||
// ── Artikel laden ──
|
||
async function loadArticles() {{
|
||
try {{
|
||
const resp = await fetch(BASE + '/nanobanana/articles?key=' + KEY);
|
||
const data = await resp.json();
|
||
const sel = document.getElementById('articleSelect');
|
||
sel.innerHTML = '<option value="">-- Artikel wählen --</option>';
|
||
(data.articles || []).forEach(a => {{
|
||
sel.innerHTML += '<option value="' + a.filename + '">' + a.title + '</option>';
|
||
}});
|
||
}} catch (e) {{}}
|
||
}}
|
||
|
||
// ── Zuordnen ──
|
||
document.getElementById('assignBtn').addEventListener('click', async () => {{
|
||
const article = document.getElementById('articleSelect').value;
|
||
if (!article) {{
|
||
showToast('Bitte Artikel auswählen', 'error');
|
||
return;
|
||
}}
|
||
if (!currentResultUrl) {{
|
||
showToast('Kein Bild zum Zuordnen', 'error');
|
||
return;
|
||
}}
|
||
|
||
const btn = document.getElementById('assignBtn');
|
||
btn.disabled = true;
|
||
btn.textContent = '⏳…';
|
||
|
||
try {{
|
||
const resp = await fetch(BASE + '/nanobanana/assign?key=' + KEY, {{
|
||
method: 'POST',
|
||
headers: {{ 'Content-Type': 'application/json' }},
|
||
body: JSON.stringify({{ article: article, imageUrl: currentResultUrl }})
|
||
}});
|
||
const data = await resp.json();
|
||
|
||
if (data.ok) {{
|
||
showToast('✅ Bild zugeordnet: ' + data.filename, 'success');
|
||
btn.textContent = '✓ Zugeordnet';
|
||
}} else {{
|
||
throw new Error(data.error);
|
||
}}
|
||
}} catch (err) {{
|
||
showToast('Fehler: ' + err.message, 'error');
|
||
btn.disabled = false;
|
||
btn.textContent = 'Zuordnen';
|
||
}}
|
||
}});
|
||
|
||
// ── Download ──
|
||
document.getElementById('downloadBtn').addEventListener('click', async () => {{
|
||
if (!currentResultUrl) return;
|
||
try {{
|
||
const resp = await fetch(currentResultUrl);
|
||
const blob = await resp.blob();
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = 'bsn-bild-' + Date.now() + '.jpg';
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
URL.revokeObjectURL(url);
|
||
}} catch (err) {{
|
||
// Fallback: direkt öffnen
|
||
window.open(currentResultUrl, '_blank');
|
||
}}
|
||
}});
|
||
|
||
// ── Neues Bild ──
|
||
document.getElementById('retryBtn').addEventListener('click', () => {{
|
||
document.getElementById('resultBox').classList.remove('visible');
|
||
currentResultUrl = null;
|
||
window.scrollTo({{ top: 0, behavior: 'smooth' }});
|
||
}});
|
||
|
||
// ── Toast ──
|
||
function showToast(msg, type) {{
|
||
const t = document.getElementById('toast');
|
||
t.textContent = msg;
|
||
t.className = 'toast ' + type + ' show';
|
||
clearTimeout(t._timeout);
|
||
t._timeout = setTimeout(() => t.classList.remove('show'), 3000);
|
||
}}
|
||
</script>
|
||
</body>
|
||
</html>"""
|
||
|
||
# ── Module-Level Review Builder Helpers ─────────────────────────────────
|
||
|
||
def _parse_multipart(raw, boundary):
|
||
"""Manuelles Multipart-Parsing (Python 3.13 kein cgi-Modul)."""
|
||
parts = {}
|
||
b = boundary.encode()
|
||
sections = raw.split(b"--" + b)
|
||
for section in sections:
|
||
if not section.strip() or section.startswith(b"--"):
|
||
continue
|
||
if b"\r\n\r\n" not in section:
|
||
continue
|
||
header_part, body = section.split(b"\r\n\r\n", 1)
|
||
if body.endswith(b"\r\n"):
|
||
body = body[:-2]
|
||
if b"\r\n--" in body:
|
||
body = body[:body.rfind(b"\r\n--")]
|
||
header_str = header_part.decode(errors="replace")
|
||
name_match = re.search(r'name="([^"]+)"', header_str)
|
||
fname_match = re.search(r'filename="([^"]+)"', header_str)
|
||
field_name = name_match.group(1) if name_match else "unknown"
|
||
if fname_match:
|
||
if field_name == "images":
|
||
cnt = sum(1 for k in parts if k.startswith("images_"))
|
||
parts[f"images_{cnt}"] = body
|
||
else:
|
||
parts[field_name] = body
|
||
else:
|
||
parts[field_name] = body.decode(errors="replace")
|
||
return parts
|
||
|
||
|
||
def _resize_image(data, output_path):
|
||
"""Bild auf 1920x1080 @ 72dpi croppen als JPEG."""
|
||
from PIL import Image
|
||
img = Image.open(io.BytesIO(data))
|
||
if img.mode in ("RGBA", "P"):
|
||
img = img.convert("RGB")
|
||
tw, th = 1920, 1080
|
||
iw, ih = img.size
|
||
scale = max(tw / iw, th / ih)
|
||
nw, nh = int(iw * scale), int(ih * scale)
|
||
img = img.resize((nw, nh), Image.LANCZOS)
|
||
left = (nw - tw) // 2
|
||
top = (nh - th) // 2
|
||
img = img.crop((left, top, left + tw, top + th))
|
||
img.save(output_path, "JPEG", quality=85, dpi=(72, 72))
|
||
|
||
|
||
def _publish_to_joomla(filepath, rubrik_id):
|
||
"""Artikel via Joomla-API veroffentlichen."""
|
||
import requests
|
||
with open(filepath, encoding="utf-8") as f:
|
||
html = f.read()
|
||
title_match = re.search(r"<title>(.*?)</title>", html)
|
||
title = title_match.group(1) if title_match else "Unbekannt"
|
||
body_match = re.search(r"<article>(.*?)</article>", html, re.DOTALL)
|
||
body = body_match.group(1).strip() if body_match else html
|
||
payload = {"data": {"type": "articles", "attributes": {
|
||
"title": title, "alias": title.lower().replace(" ", "-")[:100],
|
||
"articletext": body, "state": 1, "catid": rubrik_id, "language": "de-DE"
|
||
}}}
|
||
try:
|
||
r = requests.post(JOOMLA_API_URL, json=payload, headers={
|
||
"Accept": "application/vnd.api+json", "Content-Type": "application/json",
|
||
"X-Joomla-Token": JOOMLA_TOKEN
|
||
}, timeout=30)
|
||
if r.status_code in (200, 201):
|
||
data = r.json()
|
||
aid = data.get("data", {}).get("id", "")
|
||
if aid and '<meta name="joomla-id"' not in html:
|
||
meta_id = f'\n<meta name="joomla-id" content="{aid}">'
|
||
html = html.replace('<meta name="category-id"', meta_id + '\n<meta name="category-id"')
|
||
with open(filepath, "w", encoding="utf-8") as f:
|
||
f.write(html)
|
||
return {"ok": True, "id": aid}
|
||
return {"ok": False, "error": f"API {r.status_code}: {r.text[:200]}"}
|
||
except Exception as e:
|
||
return {"ok": False, "error": str(e)}
|
||
|
||
|
||
|
||
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()
|