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:
+369
-2
@@ -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> </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> </p>
|
||||
<p><img style="margin:10px auto;display:block;" src="{base_img}/wertung/wertung.png" alt="Wertung zum Spiel"></p>
|
||||
<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)
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user