Files
BSN-Chatsystem/fantasywelt_vorbestellungen.py
T

510 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
Fantasywelt.de Vorbestellungs-Tracker
=====================================
Ermittelt alle Spiele, die in den letzten 30 Tagen neu ins Sortiment
gekommen und als Vorbestellung (nicht lieferbar) gelistet sind.
Nutzung:
python3 fantasywelt_vorbestellungen.py # Einmal-Ausführung
python3 fantasywelt_vorbestellungen.py --days 30 # Zeitraum anpassen
Output:
- HTML: /home/hermes/workspace/fantasywelt_data/vorbestellungen.html
- Terminal: Zusammenfassung
"""
import csv, io, json, os, sys, urllib.request, urllib.error
from datetime import datetime, timedelta
from pathlib import Path
from html import escape
# ─── KONFIGURATION ───────────────────────────────────────────────────────────
CSV_URL = "https://www.fantasywelt.de/export/brettspiele.csv"
DATA_DIR = Path("/home/hermes/workspace/fantasywelt_data")
OUTPUT_FILE = DATA_DIR / "vorbestellungen.html"
AFFILIATE_TAG = "bsn=ig1UxQ0L2cNMNslzFhyr"
DEFAULT_DAYS = 30
# Aus dem Haupt-Tracker übernommene Filter
ZUBEHOER_KEYWORDS = [
"schutzhüll", "sleeve", "hülle", "hüllen", "deckbox", "deck box", "deck-box",
"schaumstoff", "feldherr", "portfolio", "zipfolio", "flexxfolio", "play-mat",
"playmat", "play mat", "toploader", "card holder", "card divider",
"pages ", "page ", "pocket pages", "kartenbox", "card case", "card box",
"faltkarton", "lagerbox", "gaming case", "gaming box",
"pro-matte", "pro-fit", "ultra pro", "ultra-snug",
"dragon shield", "board game sleeves", "board games sleeves",
"kmc small", "kmc standard", "precise-fit", "soft sleeves",
"9-pocket", "4-pocket", "8-pocket", "12-pocket", "16-pocket", "18-pocket",
"e-raptor spieler-tableau", "organizer", "würfel", "dice tower",
"raster schaumstoff", "schaumstoffeinlage", "schaumstoffzuschnitt",
"schaumstoff-set", "schaumstofeinlage", "schaumstoff abdeckung",
"half-size schaumstoff", "full-size schaumstoff", "tasche für", "medium tasche",
"miniatures pack", "playerboard", "game mat", "neoprene",
"sammelalbum", "ultimate guard", "sidewinder", "omnihive", "xenoskin",
"spielmatte", "playmat", "puzzle", "ausgrabung", "kristall",
"farblabor", "experimentier", "deck protector", "boulder",
"insert ", "insert für", "card backpack", "card case",
"collector's edition small card", "collector's edition dnd",
"enhance ", "backpack", "miniaturen-set", "miniaturen set",
"miniatures set", "token set", "token kit", "acryl",
"metallmünzen", "metal coins", "coin set", "münzset",
"spielmünzen", "upgrade pack", "upgrade kit", "conversion kit",
"bag of", "dice set", "würfelset", "würfel-set", "dice pack",
"laserox", "insert", "inlay", "organizer", "einsatz",
]
EXCLUDE_KEYWORDS = ["mängelexemplar", "mängelexemplare", "mangelexemplar"]
# ─── HILFSFUNKTIONEN ─────────────────────────────────────────────────────────
def should_skip(name):
n = name.lower()
return any(kw in n for kw in ZUBEHOER_KEYWORDS) or any(kw in n for kw in EXCLUDE_KEYWORDS)
def add_affiliate(url):
if not url:
return url
return url + ("&" if "?" in url else "?") + AFFILIATE_TAG
def download_csv():
req = urllib.request.Request(CSV_URL, headers={"User-Agent": "BSN-Vorbestellungs-Tracker/1.0"})
with urllib.request.urlopen(req, timeout=60) as resp:
data = resp.read()
try:
return data.decode("utf-8")
except UnicodeDecodeError:
return data.decode("latin-1")
def parse_csv(raw):
reader = csv.DictReader(io.StringIO(raw), delimiter=";", quotechar='"')
products = []
for row in reader:
name = row.get("Name", "").strip()
if not name:
continue
products.append({
"name": name,
"sku": row.get("Artikelnummer", "").strip(),
"ean": row.get("EAN", "").strip(),
"price": row.get("VK/Sonderpreis", "0").strip(),
"available": row.get("Verfügbarkeit", "").strip(),
"supply": row.get("Lieferstatus", "").strip(),
"created": row.get("Erstellt am", "").strip(),
"release": row.get("Erscheint am", "").strip(),
"url": row.get("Produkt-URL", "").strip(),
"image": row.get("Bild-URL", "").strip(),
"bestprice": row.get("Best-Price_Garantie!", "N").strip(),
})
return products
def format_price(s):
try:
return f"{float(s.replace(',', '.')):.2f}".replace(".", ",")
except:
return s
def detect_language(name):
n = name.upper()
if "(DE|EN)" in n or "(DEEN)" in n or "(DE/EN)" in n:
return "DE/EN"
elif "(DE)" in n or "(DEUTSCH)" in n:
return "DE"
elif "(EN)" in n or "(ENGLISH)" in n or "(ENGLISCH)" in n:
return "EN"
elif "MULTILINGUAL" in n:
return "Multi"
return ""
def parse_date(s):
"""Parse YYYY-MM-DD oder YYYY-MM-DD HH:MM:SS"""
s = s.strip()
for fmt in ["%Y-%m-%d", "%Y-%m-%d %H:%M:%S"]:
try:
return datetime.strptime(s, fmt)
except:
continue
return None
# ─── KERN-LOGIK ──────────────────────────────────────────────────────────────
def find_new_preorders(days=30):
"""Findet alle neuen Vorbestellungen der letzten N Tage."""
cutoff = datetime.now() - timedelta(days=days)
raw = download_csv()
products = parse_csv(raw)
vorbestellungen = []
skipped_zubehoer = 0
for p in products:
# Nur Vorbestellungen
if p["supply"] != "Vorbestellung":
continue
# Zubehör/Non-Games filtern
if should_skip(p["name"]):
skipped_zubehoer += 1
continue
# Erstellungsdatum prüfen
created = parse_date(p["created"])
if created is None or created < cutoff:
continue
# Release-Datum parsen
release_dt = parse_date(p["release"]) if p["release"] else None
release_str = release_dt.strftime("%d.%m.%Y") if release_dt else (p["release"] or "?")
vorbestellungen.append({
"name": p["name"],
"price": p["price"],
"url": p["url"],
"image": p["image"],
"created": created.strftime("%Y-%m-%d"),
"release": release_str,
"release_dt": release_dt,
"bestprice": p["bestprice"],
})
# Sortieren: DE zuerst, dann nach Erstellungsdatum
vorbestellungen.sort(key=lambda x: (
detect_language(x["name"]) != "DE",
detect_language(x["name"]) == "EN",
x["created"]
))
return vorbestellungen, skipped_zubehoer
# ─── HTML-GENERIERUNG ────────────────────────────────────────────────────────
def render_html(vorbestellungen, days):
"""Erzeugt eine saubere HTML-Übersicht im BSN-Stil."""
if not vorbestellungen:
return f"""<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Fantasywelt Vorbestellungen keine neuen Spiele</title>
<style>
body {{ margin:0; padding:0; background:#f9f7f4; font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif; color:#2c2416; line-height:1.75; }}
article {{ max-width:720px; margin:0 auto; padding:3rem 1.5rem; background:#fff; box-shadow:0 2px 12px rgba(0,0,0,0.06); }}
</style>
</head>
<body><article>
<p style="color:#6b7280;text-align:center;padding:2rem 0;">Keine neuen Vorbestellungen in den letzten {days} Tagen.</p>
</article></body></html>"""
rows = []
for i, p in enumerate(vorbestellungen):
name = escape(p["name"])
price = format_price(p["price"])
url = escape(add_affiliate(p["url"]))
lang = detect_language(p["name"])
release = p["release"]
bestprice = p["bestprice"]
# Badges
badges = []
if lang == "DE":
badges.append('<span style="display:inline-block;background:#20228a;color:#fff;padding:1px 6px;border-radius:3px;font-size:0.7rem;margin-left:4px;">DE</span>')
elif lang == "EN":
badges.append('<span style="display:inline-block;background:#6b7280;color:#fff;padding:1px 6px;border-radius:3px;font-size:0.7rem;margin-left:4px;">EN</span>')
elif lang == "Multi":
badges.append('<span style="display:inline-block;background:#10b981;color:#fff;padding:1px 6px;border-radius:3px;font-size:0.7rem;margin-left:4px;">Multi</span>')
if bestprice == "Y":
badges.append('<span style="display:inline-block;background:#f59e0b;color:#fff;padding:1px 6px;border-radius:3px;font-size:0.7rem;margin-left:4px;">Best-Preis</span>')
# Release-Datum prüfen (Zukunft = Warnung)
release_badge = ""
if p.get("release_dt") and p["release_dt"] > datetime.now():
release_badge = f' <span style="display:inline-block;background:#dc3545;color:#fff;padding:1px 6px;border-radius:3px;font-size:0.7rem;margin-left:4px;">⚠ ET: {release}</span>'
elif p.get("release_dt"):
release_badge = f' <span style="display:inline-block;background:#e8e0d3;color:#5a4a3a;padding:1px 6px;border-radius:3px;font-size:0.7rem;margin-left:4px;">ET: {release}</span>'
badge_html = "".join(badges)
row = f"""<tr class="{'even' if i % 2 == 1 else ''}">
<td style="padding:0.6rem 0.7rem;border-bottom:1px solid #e8e0d3;">
<a href="{url}" target="_blank" rel="sponsored" style="color:#c75b3a;font-weight:600;text-decoration:none;">{name}</a>
{badge_html}{release_badge}
</td>
<td style="text-align:right;padding:0.6rem 0.7rem;border-bottom:1px solid #e8e0d3;white-space:nowrap;font-weight:700;color:#20228a;">{price} €</td>
<td style="text-align:center;padding:0.6rem 0.7rem;border-bottom:1px solid #e8e0d3;">
<a href="{url}" target="_blank" rel="sponsored" style="display:inline-block;background:#20228a;color:#fff;text-decoration:none;padding:5px 14px;border-radius:99px;font-size:0.8rem;font-weight:700;white-space:nowrap;">Vorbestellen →</a>
</td>
</tr>"""
rows.append(row)
table_html = "\n".join(rows)
de_count = sum(1 for p in vorbestellungen if detect_language(p["name"]) == "DE")
en_count = sum(1 for p in vorbestellungen if detect_language(p["name"]) == "EN")
multi_count = sum(1 for p in vorbestellungen if detect_language(p["name"]) in ("Multi", "DE/EN"))
return f"""<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Fantasywelt Vorbestellungen Neue Spiele der letzten {days} Tage</title>
<style>
body {{ margin:0; padding:0; background:#f9f7f4; font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif; color:#2c2416; line-height:1.75; }}
article {{ max-width:720px; margin:0 auto; padding:2rem 1.5rem; background:#fff; box-shadow:0 2px 12px rgba(0,0,0,0.06); }}
h1 {{ font-size:1.5rem; margin-top:0; color:#1a1a1a; }}
h2 {{ font-size:1.2rem; margin:1.5rem 0 0.75rem; color:#333; border-bottom:2px solid #e8e0d3; padding-bottom:0.3rem; }}
.stats {{ display:flex; gap:1rem; flex-wrap:wrap; margin:1rem 0 1.5rem; }}
.stat {{ background:#f0ebe0; border-radius:8px; padding:0.4rem 1rem; font-size:0.85rem; }}
.stat strong {{ color:#20228a; }}
table {{ width:100%; border-collapse:collapse; margin:1rem 0; font-size:0.9rem; }}
th {{ background:#f0ebe0; text-align:left; padding:0.5rem 0.7rem; border-bottom:2px solid #d5c9b3; font-weight:600; }}
th:last-child {{ text-align:center; }}
tr.even td {{ background:#faf7f2; }}
.qc {{ font-size:0.75rem; color:#aaa; margin-top:2rem; border-top:1px solid #e8e0d3; padding-top:0.8rem; }}
@media (max-width:500px) {{
article {{ padding:1rem 0.7rem; }}
td, th {{ font-size:0.8rem; padding:0.4rem; }}
}}
</style>
</head>
<body>
<article>
<h1>🎯 Neue Vorbestellungen bei Fantasywelt</h1>
<p style="color:#6b7280;font-size:0.85rem;margin-bottom:1.5rem;">
Folgende Brett- und Kartenspiele sind in den letzten {days} Tagen neu ins Fantasywelt-Sortiment aufgenommen worden
und aktuell als <strong>Vorbestellung</strong> gelistet (nicht sofort lieferbar).
Stand: {datetime.now().strftime('%d.%m.%Y')}
</p>
<div class="stats">
<div class="stat">Gesamt: <strong>{len(vorbestellungen)}</strong></div>
<div class="stat">🇩🇪 DE: <strong>{de_count}</strong></div>
<div class="stat">🇬🇧 EN: <strong>{en_count}</strong></div>
<div class="stat">🌐 Multi: <strong>{multi_count}</strong></div>
</div>
<table>
<thead>
<tr><th>Spiel</th><th style="text-align:right;">Preis</th><th style="text-align:center;"></th></tr>
</thead>
<tbody>
{table_html}
</tbody>
</table>
<p class="qc">Alle Preise inkl. MwSt. · Links sind Affiliate-Links (bsn=ig1UxQ0L2cNMNslzFhyr) · Datenquelle: <a href="https://www.fantasywelt.de/export/brettspiele.csv">Fantasywelt CSV-Export</a></p>
</article>
</body>
</html>"""
# ─── MAIN ────────────────────────────────────────────────────────────────────
def build_article_html(vorbestellungen):
"""Baut den kompletten Joomla-Artikel mit ALLEN Vorbestellungen."""
from html import escape as esc
rows = []
for p in vorbestellungen:
name = esc(p["name"])
price = format_price(p["price"])
url = esc(add_affiliate(p["url"]))
lang = detect_language(p["name"])
release = p["release"]
badges = ''
if lang == 'DE':
badges += '<span style="display:inline-block;background:#20228a;color:#fff;padding:1px 6px;border-radius:3px;font-size:0.7rem;margin-left:4px;">DE</span>'
elif lang == 'EN':
badges += '<span style="display:inline-block;background:#6b7280;color:#fff;padding:1px 6px;border-radius:3px;font-size:0.7rem;margin-left:4px;">EN</span>'
elif lang in ('Multi','DE/EN'):
badges += '<span style="display:inline-block;background:#10b981;color:#fff;padding:1px 6px;border-radius:3px;font-size:0.7rem;margin-left:4px;">Multi</span>'
if p["bestprice"] == "Y":
badges += '<span style="display:inline-block;background:#f59e0b;color:#fff;padding:1px 6px;border-radius:3px;font-size:0.7rem;margin-left:4px;">Best-Preis</span>'
et_badge = ''
if p.get("release_dt") and p["release_dt"] > datetime.now():
et_badge = f' <span style="display:inline-block;background:#dc3545;color:#fff;padding:1px 6px;border-radius:3px;font-size:0.7rem;margin-left:4px;">⚠ ET: {release}</span>'
elif p.get("release_dt"):
et_badge = f' <span style="display:inline-block;background:#e8e0d3;color:#5a4a3a;padding:1px 6px;border-radius:3px;font-size:0.7rem;margin-left:4px;">ET: {release}</span>'
row = f'''<tr>
<td style="padding:0.5rem 0.7rem;border-bottom:1px solid #e8e0d3;">
<a href="{url}" target="_blank" rel="sponsored" style="color:#c75b3a;font-weight:600;">{name}</a>
{badges}{et_badge}
</td>
<td style="text-align:right;padding:0.5rem 0.7rem;border-bottom:1px solid #e8e0d3;white-space:nowrap;font-weight:700;color:#20228a;">{price} €</td>
<td style="text-align:center;padding:0.5rem 0.7rem;border-bottom:1px solid #e8e0d3;">
<a href="{url}" target="_blank" rel="sponsored" style="display:inline-block;background:#20228a;color:#fff;text-decoration:none;padding:4px 12px;border-radius:99px;font-size:0.8rem;font-weight:700;">Vorbestellen →</a>
</td>
</tr>'''
rows.append(row)
table = '\n'.join(rows)
de_count = sum(1 for p in vorbestellungen if detect_language(p["name"]) == "DE")
en_count = sum(1 for p in vorbestellungen if detect_language(p["name"]) == "EN")
multi_count = len(vorbestellungen) - de_count - en_count
heute = datetime.now().strftime("%d.%m.%Y")
kw = datetime.now().isocalendar()[1]
return 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="{len(vorbestellungen)} neue Brett- und Kartenspiele sind in den letzten 30 Tagen bei Fantasywelt als Vorbestellung gelistet. Alle Preise, Erscheinungstermine und Direktlinks.">
<meta name="keywords" content="Fantasywelt, Vorbestellungen, Brettspiele, Neuerscheinungen, Preorder">
<meta name="joomla-id" content="15378">
<title>Fantasywelt Vorbestellungen: {len(vorbestellungen)} neue Spiele vorbestellbar (KW {kw})</title>
<style>
body {{ margin: 0; padding: 0; background: #f9f7f4; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; color: #2c2416; line-height: 1.75; }}
article {{ max-width: 720px; margin: 0 auto; padding: 2rem 1.5rem; background: #fff; box-shadow: 0 2px 12px rgba(0,0,0,0.06); }}
h1 {{ font-size: 1.6rem; margin-top: 0; color: #1a1a1a; }}
p {{ margin: 0 0 1rem; }}
a {{ color: #c75b3a; text-decoration: none; }}
a:hover {{ text-decoration: underline; }}
.meta {{ color: #999; font-size: 0.85rem; margin-bottom: 2rem; }}
.stats {{ display: flex; gap: 1rem; flex-wrap: wrap; margin: 1rem 0 1.5rem; }}
.stat {{ background: #f0ebe0; border-radius: 8px; padding: 0.4rem 1rem; font-size: 0.85rem; }}
.stat strong {{ color: #20228a; }}
table {{ width: 100%; border-collapse: collapse; margin: 1rem 0; font-size: 0.9rem; }}
th {{ background: #f0ebe0; text-align: left; padding: 0.5rem 0.7rem; border-bottom: 2px solid #d5c9b3; font-weight: 600; }}
td {{ padding: 0.5rem 0.7rem; border-bottom: 1px solid #e8e0d3; }}
tr:nth-child(even) td {{ background: #faf7f2; }}
.qc {{ font-size: 0.75rem; color: #aaa; margin-top: 2rem; border-top: 1px solid #e8e0d3; padding-top: 0.8rem; }}
@media (max-width: 500px) {{
article {{ padding: 0.8rem 0.5rem; }}
td, th {{ font-size: 0.78rem; padding: 0.35rem; }}
}}
</style>
</head>
<body>
<article>
<h1>Fantasywelt Vorbestellungen: {len(vorbestellungen)} neue Spiele vorbestellbar (KW {kw})</h1>
<p class="meta">{heute} · Vorbestellungen · Automatisiert</p>
<p>In den letzten 30 Tagen hat Fantasywelt {len(vorbestellungen)} neue Brett- und Kartenspiele ins Sortiment aufgenommen, die aktuell ausschließlich als <strong>Vorbestellung</strong> gelistet sind — also nicht sofort lieferbar, aber reservierbar.</p>
<hr id="system-readmore" />
<div class="stats">
<div class="stat">Gesamt: <strong>{len(vorbestellungen)}</strong></div>
<div class="stat">🇩🇪 DE: <strong>{de_count}</strong></div>
<div class="stat">🇬🇧 EN: <strong>{en_count}</strong></div>
<div class="stat">🌐 Multi: <strong>{multi_count}</strong></div>
</div>
<table>
<thead>
<tr><th>Spiel</th><th style="text-align:right;">Preis</th><th style="text-align:center;"></th></tr>
</thead>
<tbody>
{table}
</tbody>
</table>
<p class="qc">QC: ✅ {len(vorbestellungen)} Vorbestellungen via CSV-Export · ✅ Zubehör/Non-Games ausgefiltert · ✅ Affiliate-Links (bsn=ig1UxQ0L2cNMNslzFhyr) · ✅ Automatisiert jeden Freitag</p>
</article>
</body>
</html>'''
def publish_article(html_content, article_filename="artikel_fantasywelt_vorbestellungen_auto.html"):
"""Sendet den Artikel-HTML an den Article-Server (localhost:8890/publish)."""
import urllib.request, base64, json as _json
article_path = DATA_DIR.parent / article_filename
with open(article_path, "w", encoding="utf-8") as f:
f.write(html_content)
auth = base64.b64encode(b"DK-Adminchef:Mcik7%vbdhXa_").decode()
payload = _json.dumps({"files": [article_filename], "live": False}).encode()
req = urllib.request.Request(
f"http://localhost:8890/publish?key=br3ttsp1el-n3ws-2026",
data=payload,
headers={
"Authorization": f"Basic {auth}",
"Content-Type": "application/json",
},
method="POST"
)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
result = _json.loads(resp.read())
return result
except Exception as e:
return {"ok": False, "error": str(e)}
def main():
import argparse
parser = argparse.ArgumentParser(description="Fantasywelt Vorbestellungs-Tracker")
parser.add_argument("--days", type=int, default=DEFAULT_DAYS)
parser.add_argument("--publish-article", action="store_true")
args = parser.parse_args()
days = args.days
print(f"🔍 Suche neue Vorbestellungen der letzten {days} Tage...")
vorbestellungen, skipped = find_new_preorders(days)
print(f"{len(vorbestellungen)} neue vorbestellbare Spiele gefunden")
print(f"🚫 {skipped} Zubehör-Artikel ausgefiltert")
print()
# Kurzübersicht für Terminal
de_games = [p for p in vorbestellungen if detect_language(p["name"]) == "DE"]
en_games = [p for p in vorbestellungen if detect_language(p["name"]) == "EN"]
if de_games:
print(f"─── DE-Vorbestellungen ({len(de_games)}) ───")
for p in de_games[:15]:
print(f" {p['name'][:60]} | {format_price(p['price'])} € | ET: {p['release']}")
if len(de_games) > 15:
print(f" ... und {len(de_games) - 15} weitere")
if en_games:
print(f"\n─── EN-Vorbestellungen ({len(en_games)}) ───")
for p in en_games[:5]:
print(f" {p['name'][:60]} | {format_price(p['price'])} € | ET: {p['release']}")
if len(en_games) > 5:
print(f" ... und {len(en_games) - 5} weitere")
# HTML-Übersicht (immer)
html = render_html(vorbestellungen, days)
DATA_DIR.mkdir(parents=True, exist_ok=True)
with open(OUTPUT_FILE, "w", encoding="utf-8") as f:
f.write(html)
print(f"\n📄 HTML-Übersicht: {OUTPUT_FILE}")
# Joomla-Artikel publizieren
if args.publish_article:
print("\n📰 Baue kompletten Artikel mit allen Spielen...")
article_html = build_article_html(vorbestellungen)
result = publish_article(article_html)
if result.get("ok"):
print(f"✅ Artikel 15378 aktualisiert ({len(vorbestellungen)} Spiele)")
if result.get("errors"):
for e in result["errors"]:
print(f" ⚠️ {e}")
else:
print(f"❌ Fehler: {result.get('error', 'unbekannt')}")
else:
print(f"🌐 http://185.162.249.159:8890/fantasywelt_data/vorbestellungen.html?key=br3ttsp1el-n3ws-2026")
if __name__ == "__main__":
main()