feat: Review-Builder — Word-Datei + Bilder hochladen, LLM-strukturiert HTML bauen

- GET /review-builder: Upload-Formular (Word .docx + Bilder Drag/Drop + Metadaten)
- POST /review-builder/build: Word parsen → LLM (DeepSeek Flash) strukturiert
  Teaser/Spielerklärung/Fazit → HTML bauen → im Workspace speichern
- Bild-Resize: 1920x1080 @ 72dpi (PIL), Bilderverzeichnis/01/{buchstabe}/{spiel}/
- review_config.json: 25+ Autoren, 5 Disclosures, 5 Rubriken, Komplexitätsstufen
- Live-Publish via Joomla-API optional (Button: Vorschau / Veröffentlichen)
- Link im Dashboard: 📝 Review-Builder
This commit is contained in:
Hermes Agent
2026-07-13 16:36:00 +02:00
parent 1a681e00b2
commit d02c9906a9
77 changed files with 14176 additions and 495 deletions
+369 -2
View File
@@ -29,6 +29,11 @@ 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"
@@ -188,7 +193,6 @@ SKIP_PATTERNS = [
r'^envyborn', r'^brettspiel_news_newsletter', r'^boardgamewire',
r'^ddg_', r'^blocked', r'^error', r'^just.a.moment',
r'^duckduckgo', r'^update.regarding',
r'deals', r'kennerspiele', r'kombi_deals', r'top_deals',
r'kinderspiele_deals', r'amazon_deals', r'amazon_primeday',
]
@@ -528,7 +532,7 @@ def build_index(articles: list[dict]) -> str:
<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="/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>
<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">
@@ -926,6 +930,12 @@ h1{color:#c0392b;}p{color:#888;}</style></head>
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))
@@ -1003,6 +1013,10 @@ h1{color:#c0392b;}p{color:#888;}</style></head>
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)
@@ -1840,6 +1854,278 @@ h1{color:#c0392b;}p{color:#888;}</style></head>
"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. Word-Datei hochladen</h2>
<div class="drop-zone" id="wordDrop">
<div class="drop-zone-label"><strong>Word-Datei hier ablegen</strong> oder klicken (.docx)</div>
<input type="file" id="wordInput" accept=".docx" style="display:none">
</div><ul class="file-list" id="wordList"></ul></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 *</label><input type="text" name="spielname" id="spielname" required></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>
<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 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 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)}}
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();if(!s){{st('Spielname fehlt','err');return}}if(!wordFile){{st('Word-Datei fehlt','err');return}}var fd=new FormData();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('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: Word parsen, LLM-strukturieren, 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)
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 ("word",) and not k.startswith("images_")}
if not word_data:
self._json_reply(400, {"ok": False, "error": "Keine Word-Datei"})
return
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 = meta.get("spielname", "Unbekannt").strip()
autor_key = meta.get("autor", "basti").strip()
rubrik_id = int(meta.get("rubrik", "8"))
komplex = meta.get("komplexitaet", "").strip()
disclosure_id = meta.get("disclosure", "verlag_gestellt").strip()
verlag_name = meta.get("verlag", "").strip()
fantasywelt_url = meta.get("fantasywelt_url", "").strip()
so_url = meta.get("so_url", "").strip()
do_publish = meta.get("publish", "0") == "1"
cfg = REVIEW_CONFIG
author_info = cfg.get("authors", {}).get(autor_key, {"name": autor_key, "banner": "sebastian_meine_meinung_uberschrift.png"})
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
# LLM-Strukturierung
teaser = word_text[:300]
erklarung = word_text
fazit = ""
try:
prompt = (f'Analysiere den Brettspiel-Review-Text fur "{spielname}". '
f'Extrahiere:\n1. TEASER (1-3 einleitende Satze)\n'
f'2. SPIELERKLARUNG (ausfuhrliche Spielbeschreibung)\n'
f'3. FAZIT (personliche Meinung/Bewertung)\n\n'
f'Ausgabeformat:\n---TEASER---\n[Text]\n---ERKLAERUNG---\n[Text]\n---FAZIT---\n[Text]\n\n'
f'Text:\n{word_text[:8000]}')
from urllib.request import Request, urlopen
api_body = json.dumps({
"model": "deepseek-v4-flash:cloud", "provider": "datenhimmel",
"messages": [{"role": "system", "content": "Du bist ein praziser Textanalysator."},
{"role": "user", "content": prompt}],
"temperature": 0.1
}).encode()
req = Request("https://ui.datenhimmel.work/api/v1/chat/completions",
data=api_body, headers={"Content-Type": "application/json",
"Authorization": "Bearer sk-ce3...b6db"})
resp = urlopen(req, timeout=30)
result = json.loads(resp.read().decode())
llm_text = result["choices"][0]["message"]["content"]
tm = re.search(r'---TEASER---\s*(.*?)\s*---ERKLAERUNG---', llm_text, re.DOTALL)
em = re.search(r'---ERKLAERUNG---\s*(.*?)\s*---FAZIT---', llm_text, re.DOTALL)
fm = re.search(r'---FAZIT---\s*(.*)', llm_text, re.DOTALL)
if tm: teaser = tm.group(1).strip()
if em: erklarung = em.group(1).strip()
if fm: fazit = fm.group(1).strip()
except Exception as e:
print(f"[review-builder] LLM fehlgeschlagen: {e}", flush=True)
# Bilder speichern & resizen
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"]
if not teaser.startswith("<"): teaser = f"<p>{teaser}</p>"
if not erklarung.startswith("<"): erklarung = f"<p>{erklarung.replace(chr(10)+chr(10), '</p><p>')}</p>"
if fazit and not fazit.startswith("<"): fazit = f"<p>{fazit.replace(chr(10)+chr(10), '</p><p>')}</p>"
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="Jetzt 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'
gal_path = f"01/{erster}/{ordner}"
body = f"""{teaser}
<hr id="system-readmore">
<p>&nbsp;</p>
<p><img style="margin:10px auto;display:block;" src="{base_img}/wertung/info.png" alt="Fakten zum Spiel"></p>
{disc_html}
<h2>So spielt sich {spielname}</h2>
{erklarung}
<hr class="system-pagebreak" title="Fazit + Wertung + Bilder vom Spiel">
<h2><img src="images/banners/redaktion/{autor_banner}" alt="Meine Meinung"></h2>
{fazit}
<p>&nbsp;</p>
<p><img style="margin:10px auto;display:block;" src="{base_img}/wertung/wertung.png" alt="Wertung zum Spiel"></p>
<p>&nbsp;</p>
<p style="text-align:center;">{{module Discord-Hinweis Tests}}</p>
<p>&nbsp;</p>
{aff_html}
<p>&nbsp;</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)
desc = teaser.replace("<p>", "").replace("</p>", "")[:150]
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: {spielname} \u2014 {desc}">
<meta name="keywords" content="{spielname},Brettspiel,Review,Bewertung,{autor_name}">
<meta name="category-id" content="{rubrik_id}">
<title>{spielname} \u2014 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")
@@ -2381,6 +2667,87 @@ function showToast(msg, type) {{
</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