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
+172 -215
View File
@@ -1,271 +1,228 @@
#!/usr/bin/env python3
"""Merge GF+KS data, build HTML article"""
import json, datetime, re
"""Generate the crowdfunding article HTML from merged data."""
import json, os, sys
from datetime import datetime, timezone
# Load data
with open('/home/hermes/workspace/gf_filtered.json') as f:
gf = json.load(f)
with open('/home/hermes/workspace/ks_data.json') as f:
ks = json.load(f)
now = datetime.now(timezone.utc)
kw = now.isocalendar()[1]
today_de = now.strftime("%d. %B %Y")
# German month names
months_de = {"January":"Januar","February":"Februar","March":"März","April":"April","May":"Mai","June":"Juni",
"July":"Juli","August":"August","September":"September","October":"Oktober","November":"November","December":"Dezember"}
for en, de in months_de.items():
today_de = today_de.replace(en, de)
# Normalize: all to EUR (1 USD = 0.93 EUR)
USD_TO_EUR = 0.93
with open("/home/hermes/workspace/merged_data.json") as f:
data = json.load(f)
now = datetime.datetime.now(datetime.timezone.utc)
# Sort by USD funding
data.sort(key=lambda x: x["funding_usd"], reverse=True)
# Process Gamefound
gf_entries = []
for p in gf:
funding = p['fundsGathered']
goal = p['campaignGoal']
currency = p.get('currencyShortName', 'EUR')
if currency == 'USD':
funding = funding * USD_TO_EUR
goal = goal * USD_TO_EUR
elif currency == 'PLN':
funding = funding * 0.23 # ~1 PLN = 0.23 EUR
goal = goal * 0.23
end_str = p.get('campaignEndDate', '')
days_left = -1
if end_str:
end = datetime.datetime.fromisoformat(end_str.replace('Z', '+00:00'))
days_left = max(0, (end - now).days)
pct = round(funding / max(goal, 1) * 100, 1)
# Get image URL — use smaller version
img = p.get('projectImageUrl', '')
gf_entries.append({
'name': p['projectName'],
'url': p['projectHomeUrl'],
'img': img,
'funding_eur': round(funding, 2),
'goal_eur': round(goal, 2),
'pct': pct,
'backers': p['backerCount'],
'days_left': days_left,
'source': 'gamefound',
'currency_orig': currency,
'funding_display': f"{funding:,.0f} {currency}",
})
top10 = data[:10]
endspurte = [p for p in data if p["days_left"] <= 3 and p["funding_usd"] >= 500]
# Geheimtipps: well-funded smaller campaigns
geheimtipps = [p for p in data if p["funding_usd"] >= 1000 and p["pct"] >= 100
and p["backers"] >= 30 and p["funding_usd"] < 50000
and p not in top10]
# Process Kickstarter
ks_entries = []
ks_skip = ['dice', 'terrain', 'miniature', 'STL', 'token', 'map pack', 'battle map',
'3d print', 'accessory', 'D&D', 'DnD', 'RPG supplement', 'dice bag',
'dice tray', 'playmat', 'character sheet', 'spellbook', 'rpg',
'5e', '5th edition', "player's handbook", 'handbook', 'props', 'mapcase',
'touchscreen', '15mm', '28mm', '32mm', 'resin', 'stl', 'soundtrack',
'soundscapes', 'music album']
for p in ks:
name_lower = p['name'].lower()
skip = False
for kw in ks_skip:
if kw in name_lower:
skip = True
break
if skip:
continue
funding = p['pledged']
goal = p['goal']
currency = p['currency']
if currency == 'USD':
funding_eur = funding * USD_TO_EUR
goal_eur = goal * USD_TO_EUR
def fmt_funds(p):
if p["currency"] == "EUR":
return f'{p["funds"]:,.0f}'.replace(',', '.')
else:
funding_eur = funding
goal_eur = goal
end_raw = p.get('deadline', '')
days_left = -1
if end_raw:
if isinstance(end_raw, (int, float)):
end = datetime.datetime.fromtimestamp(end_raw, tz=datetime.timezone.utc)
else:
end = datetime.datetime.fromisoformat(str(end_raw).replace('Z', '+00:00'))
days_left = max(0, (end - now).days)
pct = round(funding / max(goal, 1) * 100, 1)
ks_entries.append({
'name': p['name'].replace('&amp;', '&'),
'url': p['url'],
'img': p.get('photo', ''),
'funding_eur': round(funding_eur, 2),
'goal_eur': round(goal_eur, 2),
'pct': pct,
'backers': p['backers'],
'days_left': days_left,
'source': 'kickstarter',
'currency_orig': currency,
'funding_display': f"${funding:,.0f}",
})
return f'{p["funds"]:,.0f} $'.replace(',', '.')
print(f"Gamefound: {len(gf_entries)}, Kickstarter: {len(ks_entries)}")
def fmt_goal(p):
if p["currency"] == "EUR":
return f'{p["goal"]:,.0f}'.replace(',', '.')
else:
return f'{p["goal"]:,.0f} $'.replace(',', '.')
# Merge and sort by funding_eur
all = gf_entries + ks_entries
all.sort(key=lambda x: x['funding_eur'], reverse=True)
print(f"Total: {len(all)}")
# Quick stats
total_eur = sum(e['funding_eur'] for e in all)
total_backers = sum(e['backers'] for e in all)
gf_count = sum(1 for e in all if e['source'] == 'gamefound')
ks_count = sum(1 for e in all if e['source'] == 'kickstarter')
print(f"Gesamtfunding: {total_eur:,.0f} EUR, {total_backers:,} Backers, {gf_count} GF + {ks_count} KS")
# Save merged
with open('/home/hermes/workspace/merged.json', 'w') as f:
json.dump(all, f, indent=2, ensure_ascii=False)
# Now build the HTML
KW = 27 # Current calendar week
# Top 10
top10 = all[:10]
def rank_class(i):
if i == 0: return 'rank-1'
elif i == 1: return 'rank-2'
elif i == 2: return 'rank-3'
return ''
def row_class(i):
if i == 0: return 'row-mega'
elif i < 10: return 'row-highlight'
return ''
def fmt_pct(p):
return f'{p["pct"]} %' if p["pct"] == int(p["pct"]) else f'{p["pct"]:.0f} %'
def days_class(d):
if d <= 1: return 'days-urgent'
elif d <= 3: return 'days-warning'
return 'days-ok'
if d <= 1: return "days-urgent"
elif d <= 3: return "days-warning"
return "days-ok"
def source_icon(source):
if source == 'gamefound':
def rank_class(n):
if n == 1: return "rank-1"
elif n == 2: return "rank-2"
elif n == 3: return "rank-3"
return ""
def platform_cell(p):
if p["platform"] == "Gamefound":
return '<td style="text-align:center"><span class="badge"><img src="https://www.brettspiel-news.de/images/Spiele/2026/logo_gamefound.png" alt="Gamefound" /></span></td>'
else:
return '<td style="text-align:center"><span class="badge"><img src="https://www.brettspiel-news.de/images/Spiele/2026/logo_kickstarter.png" alt="Kickstarter" /></span></td>'
def funding_display(e):
return e['funding_display']
def cover_img(p):
if p["image"]:
# Use small thumbnail - Gamefound images might be too large, use inline CSS resize
return f'<img src="{p["image"]}" alt="{p["name"]}" style="width:60px;height:60px;object-fit:cover;border-radius:4px;" loading="lazy" />'
return ""
def build_table_row(e, rank=None, show_rank=True):
rc = row_class(rank-1) if rank and rank <= 10 else ''
rk = f'<td class="rank"><span class="rank-badge {rank_class(rank-1)}">{rank}</span></td>' if show_rank else ''
img_tag = f'<img src="{e["img"]}" alt="{e["name"]}" style="width:60px;height:60px;object-fit:cover;border-radius:4px;" loading="lazy">' if e['img'] else ''
name_cell = f'<td><div style="display:flex;align-items:center;gap:0.5rem;">{img_tag}<div><a href="{e["url"]}" target="_blank"><strong>{e["name"]}</strong></a></div></div></td>'
days = f'<td class="num"><span class="{days_class(e["days_left"])}">{e["days_left"]}</span></td>'
if not show_rank:
return f'<tr class="{rc}">{name_cell}{source_icon(e["source"])}<td class="funding">{e["funding_display"]}</td><td class="pct">{e["pct"]}&nbsp;%</td><td class="num">{e["backers"]}</td>{days}</tr>'
return f'<tr class="{rc}">{rk}{name_cell}{source_icon(e["source"])}<td class="funding">{e["funding_display"]}</td><td class="pct">{e["pct"]}&nbsp;%</td><td class="num">{e["backers"]}</td>{days}</tr>'
def game_cell(p, rank=None):
img_html = cover_img(p)
url = p["url"]
name = p['name']
if rank and rank <= 3:
name_html = f'<a href="{url}" target="_blank"><strong>{name}</strong></a>'
elif rank and rank <= 10:
name_html = f'<a href="{url}" target="_blank"><strong>{name}</strong></a>'
else:
name_html = f'<a href="{url}" target="_blank">{name}</a>'
if img_html:
return f'<td><div style="display:flex;align-items:center;gap:0.5rem;">{img_html}<div>{name_html}</div></div></td>'
else:
return f'<td>{name_html}</td>'
# Endspurte: days_left <= 3, sorted by days_left
endspurte = sorted([e for e in all if 1 <= e['days_left'] <= 3], key=lambda x: x['days_left'])
def table_row(p, rank=None, extra_class=""):
r = rank or 0
rc = rank_class(r) if r <= 3 else ""
badge = f'<span class="rank-badge {rc}">{r}</span>' if r else ""
cls = extra_class
dc = days_class(p["days_left"])
# Determine row class
if p["funding_usd"] >= 100000:
if "row-mega" not in cls:
cls = (cls + " row-mega").strip()
row = '<tr'
if cls:
row += f' class="{cls}"'
row += '>\n'
if rank:
row += f' <td class="rank">{badge}</td>\n'
row += f' {game_cell(p, rank=r)}\n'
row += f' {platform_cell(p)}\n'
row += f' <td class="funding">{fmt_funds(p)}</td>\n'
row += f' <td class="pct">{fmt_pct(p)}</td>\n'
row += f' <td class="num">{p["backers"]:,}</td>\n'.replace(',', '.')
row += f' <td class="num"><span class="{dc}">{p["days_left"]}</span></td>\n'
row += '</tr>'
return row
# Geheimtipps: under 500 backers, over 200% funded, not in top 20
geheimtipps = sorted([e for e in all if e['backers'] < 500 and e['pct'] > 200 and e not in all[:20]], key=lambda x: x['pct'], reverse=True)[:5]
# === BUILD HTML ===
# Now build HTML
MONTHS_DE = {7: 'Juli', 8: 'August'}
today = datetime.date.today()
date_str = f"{today.day}. {MONTHS_DE[today.month]} {today.year}"
# Determine KW dates
kw_start = today - datetime.timedelta(days=today.weekday())
kw_end = kw_start + datetime.timedelta(days=6)
html = f'''<!DOCTYPE html>
html = 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="Wöchentliche Crowdfunding-Übersicht KW {KW}: {len(all)} aktive Brettspiel-Kampagnen auf Kickstarter und Gamefound mit allen Funding-Zahlen.">
<meta name="keywords" content="Crowdfunding, Kickstarter, Gamefound, Brettspiele, KW{KW}, Funding, Kampagnen">
<meta name="bsn-tags" content="Crowdfunding, Strategie, Kartenspiel, Deckbauspiel, Kooperativ">
<meta name="description" content="Crowdfunding-Übersicht KW {kw}: {len(data)} Brettspiel-Kampagnen auf Kickstarter und Gamefound im Check — Top 10, Endspurte und Geheimtipps.">
<meta name="keywords" content="Crowdfunding, Brettspiele, Kickstarter, Gamefound, KW {kw}">
<meta name="bsn-tags" content="Crowdfunding, Strategie, Kartenspiel">
<meta name="bsn-cover" content="images/Spiele/2026/crowdfunding_cover_1920x1080_2026_093650.jpg">
<title>Crowdfunding KW {KW}: {len(all)} Brettspiel-Kampagnen im Check</title>
<title>Crowdfunding-Übersicht KW {kw} {len(data)} Brettspiel-Kampagnen im Check</title>
</head>
<body>
<p class="meta">{date_str}</p>
<!-- bsn-meta:{date_str} -->
<p class="meta">{today_de}</p>
<!-- bsn-meta:{today_de} -->
<p class="dachzeile">{len(all)} aktive Brettspiel-Kampagnen auf Kickstarter und Gamefound — alle Funding-Zahlen, Endspurte und Geheimtipps der KW {KW}.</p>
<p class="dachzeile">KW {kw}: {len(top10)} dicke Fische und {len(endspurte)} Endspurte — wir checken alle {len(data)} aktiven Brettspiel-Kampagnen auf Kickstarter und Gamefound.</p>
<article>
<h2>Crowdfunding KW {KW}: {len(all)} Brettspiel-Kampagnen im Check</h2>
<p>Die Kalenderwoche {KW} bringt {len(all)} aktive Brettspiel-Crowdfunding-Kampagnen auf Kickstarter und Gamefound. Das Gesamtfunding liegt bei rund {total_eur/1000000:.1f} Millionen Euro, verteilt auf {total_backers:,} Unterstützer. {gf_count} Kampagnen laufen auf Gamefound, {ks_count} auf Kickstarter. Wir haben alle Zahlen, Endspurte und Geheimtipps für euch zusammengestellt.</p>
<p>Die KW {kw} bringt satte {len(data)} aktive Brettspiel-Kampagnen auf Kickstarter und Gamefound. Darunter echte Schwergewichte wie <strong>{top10[0]['name']}</strong> mit über {fmt_funds(top10[0])} — aber auch {sum(1 for p in data if p['days_left'] <= 1)} Kampagnen, die heute oder morgen auslaufen. Wir haben alle für euch im Blick.</p>
<hr id="system-readmore" />
"""
<h2>Die Großen: Top 10 nach Funding</h2>
<table class="bsn-table">
<tr><th>#</th><th>Spiel</th><th style="text-align:center">Plattform</th><th class="funding">Funding</th><th class="num">%</th><th class="num">Backer</th><th class="num">Tage</th></tr>
'''
# === TOP 10 TABLE ===
html += '<h2>Die Großen: Top 10 nach Funding</h2>\n'
html += '<table class="bsn-table">\n'
html += '<tr><th>#</th><th>Spiel</th><th style="text-align:center">Plattform</th><th class="funding">Funding</th><th class="num">%</th><th class="num">Backer</th><th class="num">Tage</th></tr>\n'
# Top 10 rows
for i, e in enumerate(top10):
html += build_table_row(e, rank=i+1) + '\n'
for i, p in enumerate(top10):
extra = ""
# Mark mega campaigns
if p["funding_usd"] >= 250000:
extra = "row-mega"
elif p["funding_usd"] >= 100000:
extra = "row-highlight"
html += table_row(p, rank=i+1, extra_class=extra) + '\n'
html += '''</table>
html += '</table>\n\n'
'''
# Endspurte
# === ENDSPURTE ===
if endspurte:
html += '''<h2>Endspurte: Diese Kampagnen enden in Kürze</h2>
<table class="bsn-table">
<tr><th>Spiel</th><th style="text-align:center">Plattform</th><th class="funding">Funding</th><th class="num">%</th><th class="num">Backer</th><th class="num">Tage</th></tr>
'''
for e in endspurte:
html += build_table_row(e, show_rank=False) + '\n'
es_sorted = sorted(endspurte, key=lambda x: x["days_left"])
html += '<h2>Endspurte: Diese Kampagnen laufen bald aus</h2>\n'
html += '<table class="bsn-table">\n'
html += '<tr><th>Spiel</th><th style="text-align:center">Plattform</th><th class="funding">Funding</th><th class="num">%</th><th class="num">Backer</th><th class="num">Tage</th></tr>\n'
for p in es_sorted:
extra = "row-highlight" if p["pct"] >= 100 else ""
html += table_row(p, extra_class=extra) + '\n'
html += '</table>\n\n'
# Geheimtipps
# === GEHEIMTIPPS ===
if geheimtipps:
html += '''<h2>Geheimtipps: Kleine Kampagnen mit großem Potenzial</h2>
<table class="bsn-table">
<tr><th>Spiel</th><th style="text-align:center">Plattform</th><th class="funding">Funding</th><th class="num">%</th><th class="num">Backer</th><th class="num">Tage</th></tr>
'''
for e in geheimtipps:
html += build_table_row(e, show_rank=False) + '\n'
html += '<h2>Kleine Verlage, große Kampagnen</h2>\n'
html += '<p>Diese kleineren Projekte haben ihr Ziel bereits erreicht und verdienen einen zweiten Blick:</p>\n'
html += '<table class="bsn-table">\n'
html += '<tr><th>Spiel</th><th style="text-align:center">Plattform</th><th class="funding">Funding</th><th class="num">%</th><th class="num">Backer</th><th class="num">Tage</th></tr>\n'
for p in sorted(geheimtipps, key=lambda x: x["pct"], reverse=True)[:8]:
html += table_row(p, extra_class="row-highlight") + '\n'
html += '</table>\n\n'
# Complete list
html += f'''<h2>Alle {len(all)} Kampagnen im Überblick</h2>
<table class="bsn-table">
<tr><th>#</th><th>Spiel</th><th style="text-align:center">Plattform</th><th class="funding">Funding</th><th class="num">%</th><th class="num">Backer</th><th class="num">Tage</th></tr>
'''
for i, e in enumerate(all):
html += build_table_row(e, rank=i+1) + '\n'
# === ALL CAMPAIGNS ===
html += f'<h2>Alle {len(data)} Kampagnen im Überblick</h2>\n'
html += '<table class="bsn-table">\n'
html += '<tr><th>#</th><th>Spiel</th><th style="text-align:center">Plattform</th><th class="funding">Funding</th><th class="num">%</th><th class="num">Backer</th><th class="num">Tage</th></tr>\n'
html += '''</table>
for i, p in enumerate(data):
extra = ""
if i < 10:
extra = "row-highlight"
html += table_row(p, rank=i+1, extra_class=extra) + '\n'
<span style="font-size:1.17em;font-weight:bold;display:block;margin:1em 0;"><strong>Quellen</strong></span>
html += '</table>\n\n'
# === SOURCES ===
html += f"""<span style="font-size:1.17em;font-weight:bold;display:block;margin:1em 0;"><strong>Quellen</strong></span>
<ol>
<li><a href="https://gamefound.com" target="_blank">Gamefound</a> — Aktive Crowdfunding-Kampagnen (KW ''' + str(KW) + ''')</li>
<li><a href="https://www.kickstarter.com" target="_blank">Kickstarter</a> — Tabletop Games Kategorie, aktive Projekte (KW ''' + str(KW) + ''')</li>
<li><a href="https://everythingboardgames.com" target="_blank">EverythingBoardGames</a> — Crowdfunding-Tracker für Brettspiele</li>
<li><a href="https://gamefound.com" target="_blank">Gamefound</a> — Aktive Crowdfunding-Projekte (Stand: {today_de})</li>
<li><a href="https://www.kickstarter.com" target="_blank">Kickstarter</a> — Tabletop Games Kategorie (Stand: {today_de})</li>
</ol>
<p class="bsn-recherche"><em>Recherchestand: ''' + date_str + '''</em></p>
<p class="bsn-recherche"><em>Recherchestand: {today_de}</em></p>
</article>
</body>
</html>'''
# Write HTML
with open('/home/hermes/workspace/crowdfunding_kw27.html', 'w') as f:
<!-- NANO-BANANA-PROMPT
Erstelle ein Titelbild für brettspiel-news.de — Crowdfunding-Übersicht KW {kw}:
- 16:9, 1920x1080, 72 DPI
- Stil: Editorial/Magazin, warme Beleuchtung
- Collage aus Brettspiel-Covern auf einem Holzspieltisch
- Im Zentrum: Logo-Integration "Crowdfunding KW {kw}"
- Kickstarter- und Gamefound-Logos dezent am Rand
- Farbpalette: warme Holztöne, BSN-Blau (#20228A) als Akzent
- Keine störenden Elemente im Hintergrund
-->
</body>
</html>"""
# Write output
out_path = "/home/hermes/workspace/crowdfunding_kw28_article.html"
with open(out_path, "w") as f:
f.write(html)
print(f"\nHTML written: {len(html)} chars")
print(f"Top 10: {[e['name'] for e in top10]}")
# Stats
print(f"Article written: {out_path}")
print(f"Projects: {len(data)} (GF: {sum(1 for p in data if p['platform']=='Gamefound')}, KS: {sum(1 for p in data if p['platform']=='Kickstarter')})")
print(f"Top 10 range: {fmt_funds(top10[-1])}{fmt_funds(top10[0])}")
print(f"Endspurte: {len(endspurte)}")
print(f"Geheimtipps: {len(geheimtipps)}")
print("DONE")