Files
BSN-Chatsystem/fantasywelt_neuheiten.py
T

508 lines
26 KiB
Python

#!/usr/bin/env python3
"""
Fantasywelt.de Neuheiten-Tracker v9
====================================
Starten:
python3 fantasywelt_neuheiten.py # Server auf Port 5544
python3 fantasywelt_neuheiten.py --port 8080
python3 fantasywelt_neuheiten.py --once # Einmal ohne Server
"""
import csv, io, json, os, sys, urllib.request, urllib.error
from datetime import datetime, timedelta
from pathlib import Path
from html import escape
from http.server import HTTPServer, BaseHTTPRequestHandler
from urllib.parse import urlparse, parse_qs
# ─── KONFIGURATION ───────────────────────────────────────────────────────────
CSV_URL = "https://www.fantasywelt.de/export/brettspiele.csv"
DATA_DIR = Path("/home/hermes/workspace/fantasywelt_data")
HISTORY_DIR = DATA_DIR / "history"
OUTPUT_FILE = DATA_DIR / "neuheiten.html"
SNAPSHOT_FILE = DATA_DIR / "last_snapshot.json"
CHANGES_FILE = DATA_DIR / "changes.json"
DEFAULT_PORT = 5544
DEFAULT_DAYS_NEW = 14
DEFAULT_DAYS_PREORDER = 7
AFFILIATE_TAG = "bsn=ig1UxQ0L2cNMNslzFhyr"
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","kartenbox","card case","card box",
"faltkarton","lagerbox","gaming case","gaming box",
"pro-matte","pro-fit","ultra pro","ultra-snug",
"dragon shield sleeves","dragon shield: sleeves","dragon shield schutzhüll",
"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",
]
EXCLUDE_KEYWORDS = ["mängelexemplar","mängelexemplare","mangelexemplar","%sale%"]
# ─── 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-Tracker/9.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 ""
# ─── SNAPSHOT & ÄNDERUNGSLOG ─────────────────────────────────────────────────
def load_snapshot():
if not SNAPSHOT_FILE.exists(): return None
try:
with open(SNAPSHOT_FILE, "r", encoding="utf-8") as f: return json.load(f)
except: return None
def save_snapshot(products, timestamp):
DATA_DIR.mkdir(parents=True, exist_ok=True); HISTORY_DIR.mkdir(parents=True, exist_ok=True)
snap = {"timestamp": timestamp, "products": {
p["sku"]: {"name":p["name"],"available":p["available"],"price":p["price"],"supply":p["supply"]}
for p in products if p["sku"]}}
with open(SNAPSHOT_FILE, "w", encoding="utf-8") as f: json.dump(snap, f, ensure_ascii=False, indent=2)
safe = timestamp.replace(":","-").replace(" ","_")
with open(HISTORY_DIR / f"snapshot_{safe}.json", "w", encoding="utf-8") as f: json.dump(snap, f, ensure_ascii=False, indent=2)
def load_changes():
if not CHANGES_FILE.exists(): return []
try:
with open(CHANGES_FILE, "r", encoding="utf-8") as f: return json.load(f)
except: return []
def save_changes(changes):
DATA_DIR.mkdir(parents=True, exist_ok=True)
with open(CHANGES_FILE, "w", encoding="utf-8") as f: json.dump(changes, f, ensure_ascii=False, indent=2)
def cleanup_changes(changes, keep_days=30):
cutoff = (datetime.now() - timedelta(days=keep_days)).isoformat()
return [c for c in changes if c.get("detected_at","") >= cutoff]
def cleanup_old_snapshots(keep_days=30):
if not HISTORY_DIR.exists(): return
cutoff = datetime.now() - timedelta(days=keep_days)
for f in sorted(HISTORY_DIR.glob("snapshot_*.json")):
try:
with open(f) as fh: ts = json.load(fh).get("timestamp","")
if datetime.strptime(ts[:19], "%Y-%m-%d %H:%M:%S") < cutoff: f.unlink()
except: continue
# ─── NEUHEITEN-ERKENNUNG ────────────────────────────────────────────────────
def detect_changes(products, old_snapshot, timestamp):
old_products = old_snapshot.get("products", {})
nc = []
for p in products:
if should_skip(p["name"]): continue
sku = p["sku"]
if not sku: continue
old = old_products.get(sku)
ct = None
if p["available"] == "auf Lager":
if old is None:
# Prüfe Erscheinungsdatum: Spiele älter als 120 Tage sind
# keine echten Neuheiten, sondern nur neu im Fantasywelt-Katalog
release_date = None
if p.get("release"):
try: release_date = datetime.strptime(p["release"], "%Y-%m-%d")
except: pass
if release_date and release_date < datetime.now() - timedelta(days=120):
ct = "wieder_im_lager" # altes Spiel, neu im Katalog → wie Restock
else:
ct = "neu_im_lager" # wirklich neu (oder kein release date)
elif old.get("available") != "auf Lager":
ct = "wieder_im_lager" # war schon im Katalog, jetzt wieder da
if p["supply"] == "Vorbestellung":
if old is None: ct = "neue_vorbestellung"
elif old.get("supply") != "Vorbestellung": ct = "neue_vorbestellung"
if ct: nc.append({"sku": sku, "change_type": ct, "detected_at": timestamp,
"name": p["name"], "price": p["price"],
"url": p.get("url",""), "image": p.get("image",""),
"release": p.get("release",""), "bestprice": p.get("bestprice","N")})
return nc
def initial_seed(products, days_new, days_preorder, timestamp):
cutoff_new = datetime.now() - timedelta(days=days_new)
cutoff_pre = datetime.now() - timedelta(days=days_preorder)
seed, seen = [], set()
for p in products:
if should_skip(p["name"]): continue
sku = p["sku"]
if not sku or sku in seen: continue
try: created = datetime.strptime(p["created"], "%Y-%m-%d")
except: continue
if p["supply"] == "Vorbestellung" and created >= cutoff_pre:
seen.add(sku); seed.append({"sku":sku,"change_type":"neue_vorbestellung","detected_at":timestamp,
"name":p["name"],"price":p["price"],"url":p.get("url",""),
"image":p.get("image",""),"release":p.get("release",""),
"bestprice":p.get("bestprice","N")})
elif p["available"] == "auf Lager" and created >= cutoff_new:
seen.add(sku); seed.append({"sku":sku,"change_type":"neu_im_lager","detected_at":timestamp,
"name":p["name"],"price":p["price"],"url":p.get("url",""),
"image":p.get("image",""),"release":p.get("release",""),
"bestprice":p.get("bestprice","N")})
return seed
def build_neuheiten(products, changes, days_new, days_preorder):
cutoff_new = (datetime.now() - timedelta(days=days_new)).isoformat()
cutoff_pre = (datetime.now() - timedelta(days=days_preorder)).isoformat()
pm = {p["sku"]: p for p in products if p["sku"]}
neu_lager, wieder_lager, vorb = {}, {}, {}
for c in changes:
sku, ct, det = c["sku"], c["change_type"], c.get("detected_at","")
if ct == "neu_im_lager" and det >= cutoff_new:
if sku not in neu_lager or det > neu_lager[sku]["detected_at"]: neu_lager[sku] = c
if ct == "wieder_im_lager" and det >= cutoff_new:
if sku not in wieder_lager or det > wieder_lager[sku]["detected_at"]: wieder_lager[sku] = c
if ct == "neue_vorbestellung" and det >= cutoff_pre:
if sku not in vorb or det > vorb[sku]["detected_at"]: vorb[sku] = c
neu = []
for sku, ch in neu_lager.items():
p = pm.get(sku)
if p is None:
# Fallback: Produkt nicht mehr in CSV (verkauft), nutze gespeicherte Daten
if "name" not in ch or should_skip(ch.get("name","")):
continue
p = {"name": ch["name"], "price": ch["price"], "url": ch.get("url",""),
"image": ch.get("image",""), "release": ch.get("release",""),
"bestprice": ch.get("bestprice","N"), "available": "ausverkauft",
"supply": "", "sku": sku}
elif should_skip(p["name"]):
continue
p["change_reason"] = "Neu im Shop"; p["detected_at"] = ch["detected_at"]; neu.append(p)
wieder = []
wieder_skipped = 0
for sku, ch in wieder_lager.items():
if sku in neu_lager: continue
p = pm.get(sku)
if p is None:
if "name" not in ch or should_skip(ch.get("name","")):
continue
p = {"name": ch["name"], "price": ch["price"], "url": ch.get("url",""),
"image": ch.get("image",""), "release": ch.get("release",""),
"bestprice": ch.get("bestprice","N"), "available": "ausverkauft",
"supply": "", "sku": sku}
elif should_skip(p["name"]):
continue
# "Wieder da" nur DE-Versionen — sonst ufern EN-Restocks aus
if detect_language(p["name"]) != "DE":
wieder_skipped += 1
continue
p["change_reason"] = "Wieder auf Lager"; p["detected_at"] = ch["detected_at"]; wieder.append(p)
vl = []
for sku, ch in vorb.items():
if sku in neu_lager or sku in wieder_lager: continue
p = pm.get(sku)
if p is None:
if "name" not in ch or should_skip(ch.get("name","")):
continue
p = {"name": ch["name"], "price": ch["price"], "url": ch.get("url",""),
"image": ch.get("image",""), "release": ch.get("release",""),
"bestprice": ch.get("bestprice","N"), "available": "ausverkauft",
"supply": "Vorbestellung", "sku": sku}
elif should_skip(p["name"]):
continue
p["change_reason"] = "Neue Vorbestellung"; p["detected_at"] = ch["detected_at"]; vl.append(p)
neu.sort(key=lambda p: p.get("detected_at",""), reverse=True)
wieder.sort(key=lambda p: p.get("detected_at",""), reverse=True)
vl.sort(key=lambda p: p.get("detected_at",""), reverse=True)
return neu, wieder, vl
# ─── TRACKER-KERN ────────────────────────────────────────────────────────────
def run_tracker(days_new, days_preorder):
log = []
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
raw = download_csv()
products = parse_csv(raw)
games = [p for p in products if not should_skip(p["name"])]
log.append(f"{len(games)} Spiele geladen")
old = load_snapshot(); changes = load_changes()
if old:
mode = "diff"; nc = detect_changes(products, old, timestamp)
log.append(f"🔍 {len(nc)} neue Änderungen"); changes.extend(nc)
else:
mode = "initial"; seed = initial_seed(products, days_new, days_preorder, timestamp)
log.append(f"📅 Erster Lauf: {len(seed)} Seed"); changes.extend(seed)
changes = cleanup_changes(changes, keep_days=max(days_new, days_preorder)+7)
save_changes(changes); save_snapshot(products, timestamp)
cleanup_old_snapshots(keep_days=max(days_new, days_preorder)+7)
neu, wieder, vorb = build_neuheiten(products, changes, days_new, days_preorder)
log.append(f"🆕 {len(neu)} Neuheiten, {len(wieder)} wieder da (DE), {len(vorb)} Vorbestellungen")
return neu, wieder, vorb, mode, days_new, days_preorder, log
# ─── HTML-GENERIERUNG ────────────────────────────────────────────────────────
def render_deal_cards(items, accent="#20228a"):
"""Amazon-Stil: Flexbox-Cards mit Bild, Name, Preis, Badges."""
if not items:
return '<p style="color:#6b7280;text-align:center;padding:2rem 0;">Keine Neuheiten in diesem Zeitraum.</p>'
cards = []
for p in items:
name = escape(p["name"])
price = format_price(p["price"])
url = escape(add_affiliate(p.get("url", "#")))
image = p.get("image", "")
lang = detect_language(p["name"])
bestprice = p.get("bestprice", "N")
release_str = ""
release_future = False
if p.get("release"):
try:
rd = datetime.strptime(p["release"], "%Y-%m-%d")
release_str = rd.strftime("%d.%m.%Y")
if rd > datetime.now():
release_future = True
except: pass
# Badges
badges = []
if lang:
badges.append(f'<span style="display:inline-block;background:#e8f5e9;color:#16a34a;font-size:0.65rem;font-weight:700;padding:1px 6px;border-radius:3px;text-transform:uppercase;margin-right:4px;">{escape(lang)}</span>')
if bestprice == "Y":
badges.append('<span style="display:inline-block;background:#fff3e0;color:#ea580c;font-size:0.65rem;font-weight:700;padding:1px 6px;border-radius:3px;text-transform:uppercase;margin-right:4px;">Best-Price</span>')
if release_str:
if release_future:
badges.append(f'<span style="display:inline-block;background:#ffebee;color:#dc2626;font-size:0.65rem;font-weight:700;padding:1px 6px;border-radius:3px;text-transform:uppercase;margin-right:4px;">ET: {release_str} ⚠</span>')
else:
badges.append(f'<span style="display:inline-block;background:#f3e8ff;color:#7c3aed;font-size:0.65rem;font-weight:700;padding:1px 6px;border-radius:3px;text-transform:uppercase;margin-right:4px;">Ersch. {release_str}</span>')
badge_html = "".join(badges)
# Ausverkauft-Badge
if p.get("available") == "ausverkauft":
badge_html += ' <span style="display:inline-block;background:#fef2f2;color:#dc2626;font-size:0.65rem;font-weight:700;padding:1px 6px;border-radius:3px;text-transform:uppercase;margin-right:4px;">Ausverkauft</span>'
# Image — Cloudflare schützt /media/ vor Hotlinking.
# Bild-URL wird als data-fallback gespeichert, aber nicht als <img> eingebunden.
# Im Browser-Kontext (gleiche Session) würden sie laden, cross-origin nicht.
img_html = ""
cards.append(f'''<div style="display:flex;gap:0.8rem;padding:1rem 0;border-bottom:1px solid #eee;align-items:flex-start;">
<div style="width:4px;min-height:50px;background:{accent};border-radius:2px;flex-shrink:0;align-self:stretch;"></div>
<div style="flex:1;min-width:0;">
<a href="{url}" target="_blank" style="color:#1a1d23;text-decoration:none;font-weight:600;font-size:0.95rem;line-height:1.3;">{name}</a>
<div style="margin-top:4px;">{badge_html}</div>
<div style="margin-top:6px;display:flex;align-items:baseline;gap:0.5rem;flex-wrap:wrap;">
<span style="color:{accent};font-size:1.1rem;font-weight:700;">{price} €</span>
<a href="{url}" target="_blank" style="color:{accent};text-decoration:none;font-size:0.8rem;font-weight:700;padding:4px 12px;border:1.5px solid {accent};border-radius:99px;display:inline-block;transition:all .2s;" onmouseover="this.style.background='{accent}';this.style.color='#fff';" onmouseout="this.style.background='transparent';this.style.color='{accent}';">Kaufen →</a>
</div>
</div>
</div>''')
return "".join(cards)
def _render_embed_cards(items):
"""Reine Cards ohne Container (für Copy-Button)."""
return render_deal_cards(items)
def generate_html(neu, wieder, vorb, days_new, days_preorder, mode, sections="all"):
"""Generate HTML. sections: 'all', 'neu', 'neu_wieder', 'vorbestellung'"""
now_str = datetime.now().strftime("%d.%m.%Y")
show_neu = sections in ("all", "neu", "neu_wieder")
show_wieder = sections in ("all", "neu_wieder")
show_vorb = sections in ("all", "vorbestellung")
neu_cards = render_deal_cards(neu, accent="#20228a") if (show_neu and neu) else ""
wieder_cards = render_deal_cards(wieder, accent="#20228a") if (show_wieder and wieder) else ""
vorb_cards = render_deal_cards(vorb, accent="#20228a") if (show_vorb and vorb) else ""
sections_html = []
if show_neu:
if neu:
sections_html.append(f'<h2 style="font-size:1.3rem;font-weight:700;margin:0 0 0.3rem;">Neu im Shop ({len(neu)})</h2>')
sections_html.append(f'<p style="color:#6b7280;font-size:0.82rem;margin:0 0 1rem;">Brandneue Spiele · Stand: {now_str}</p>')
sections_html.append(neu_cards)
else:
sections_html.append('<p style="color:#6b7280;text-align:center;padding:1rem 0;">Keine brandneuen Spiele in diesem Zeitraum.</p>')
if show_wieder:
if wieder:
sections_html.append(f'<div style="margin-top:2rem;">')
sections_html.append(f'<h2 style="font-size:1.3rem;font-weight:700;margin:0 0 0.3rem;">Wieder auf Lager ({len(wieder)})</h2>')
sections_html.append(f'<p style="color:#6b7280;font-size:0.82rem;margin:0 0 1rem;">Nachfrage hat sich gelohnt — diese Spiele sind zurück</p>')
sections_html.append(wieder_cards)
sections_html.append(f'</div>')
else:
sections_html.append('<div style="margin-top:2rem;"><p style="color:#6b7280;text-align:center;padding:1rem 0;">Keine Nachschub-Entdeckungen in diesem Zeitraum.</p></div>')
if show_vorb:
if vorb:
sections_html.append(f'<div style="margin-top:2rem;">')
sections_html.append(f'<h2 style="font-size:1.3rem;font-weight:700;margin:0 0 0.3rem;">Neue Vorbestellungen ({len(vorb)})</h2>')
sections_html.append(f'<p style="color:#6b7280;font-size:0.82rem;margin:0 0 1rem;">Demnächst erscheinend · Stand: {now_str}</p>')
sections_html.append(vorb_cards)
sections_html.append(f'</div>')
else:
sections_html.append('<div style="margin-top:2rem;"><p style="color:#6b7280;text-align:center;padding:1rem 0;">Keine neuen Vorbestellungen in diesem Zeitraum.</p></div>')
embed_code = f'''<div style="max-width:680px;margin:0 auto;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;color:#1a1d23;line-height:1.5;padding:0 1rem;">
{"".join(sections_html)}
<p style="color:#6b7280;font-size:0.75rem;margin-top:1.5rem;text-align:center;">
<a href="{escape(add_affiliate('https://www.fantasywelt.de'))}" target="_blank" style="color:#20228a;text-decoration:none;">Alle Angebote auf Fantasywelt.de →</a><br>
Preise und Verfügbarkeit können sich schnell ändern.
</p>
</div>'''
# Volle HTML-Seite (für Server)
full_page = f'''<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Fantasywelt.de — Neuheiten</title>
<style>
*{{box-sizing:border-box;margin:0;padding:0}}
body{{background:#fff;color:#1a1d23;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;line-height:1.5;padding:1.5rem 1rem 3rem}}
h2{{font-size:1.3rem;font-weight:700;margin:0 0 0.3rem}}
@media(max-width:600px){{
body{{padding:0.5rem 0.5rem 2rem}}
}}
</style>
</head>
<body>
{embed_code}
</body>
</html>'''
return full_page
# ─── WEBSERVER ───────────────────────────────────────────────────────────────
class Handler(BaseHTTPRequestHandler):
def log_message(self, fmt, *args): print(f"🌐 {args[0]}") if args else None
def do_GET(self):
parsed = urlparse(self.path)
if parsed.path == "/refresh": self._handle_refresh(parsed)
elif parsed.path in ("/","/index.html"): self._serve_html()
else: self.send_error(404)
def _serve_html(self):
if not OUTPUT_FILE.exists(): self._run_and_save(DEFAULT_DAYS_NEW, DEFAULT_DAYS_PREORDER)
try:
with open(OUTPUT_FILE, "r", encoding="utf-8") as f: content = f.read().encode("utf-8")
self.send_response(200); self.send_header("Content-Type","text/html; charset=utf-8")
self.send_header("Content-Length", str(len(content))); self.end_headers(); self.wfile.write(content)
except Exception as e: self.send_error(500, str(e))
def _handle_refresh(self, parsed):
qs = parse_qs(parsed.query)
try:
dn = max(1, min(90, int(qs.get("days_new",[str(DEFAULT_DAYS_NEW)])[0])))
dp = max(1, min(90, int(qs.get("days_preorder",[str(DEFAULT_DAYS_PREORDER)])[0])))
except: dn, dp = DEFAULT_DAYS_NEW, DEFAULT_DAYS_PREORDER
try:
self._run_and_save(dn, dp)
resp = json.dumps({"ok":True,"message":f"{dn}T Neuheiten / {dp}T Vorbestellungen aktualisiert"})
except Exception as e: resp = json.dumps({"ok":False,"message":str(e)})
self.send_response(200); self.send_header("Content-Type","application/json")
self.send_header("Content-Length",str(len(resp))); self.end_headers(); self.wfile.write(resp.encode("utf-8"))
def _run_and_save(self, dn, dp):
print(f"\n{'='*50}\n🔄 Tracker: {dn}T / {dp}T\n{'='*50}")
neu, wieder, vorb, mode, dn2, dp2, log = run_tracker(dn, dp)
for l in log: print(f" {l}")
html = generate_html(neu, wieder, vorb, dn2, dp2, mode)
DATA_DIR.mkdir(parents=True, exist_ok=True)
with open(OUTPUT_FILE, "w", encoding="utf-8") as f: f.write(html)
print(f" 📄 {OUTPUT_FILE}\n{'='*50}\n")
# ─── MAIN ────────────────────────────────────────────────────────────────────
def main():
port = DEFAULT_PORT
once = "--once" in sys.argv
sections = "all"
if "--sections" in sys.argv:
idx = sys.argv.index("--sections")
if idx+1 < len(sys.argv):
sections = sys.argv[idx+1]
if "--only" in sys.argv:
idx = sys.argv.index("--only")
if idx+1 < len(sys.argv):
sections = sys.argv[idx+1]
if "--port" in sys.argv:
idx = sys.argv.index("--port")
if idx+1 < len(sys.argv):
try: port = int(sys.argv[idx+1])
except: pass
if once:
dn, dp = DEFAULT_DAYS_NEW, DEFAULT_DAYS_PREORDER
for arg, t in [("--days-new","n"),("--days-preorder","p")]:
if arg in sys.argv:
idx = sys.argv.index(arg)
if idx+1 < len(sys.argv):
try:
v = int(sys.argv[idx+1])
if t=="n": dn=v
else: dp=v
except: pass
print(f"⏱️ Einmal: {dn}T / {dp}T (sections={sections})\n")
neu, wieder, vorb, mode, dn2, dp2, log = run_tracker(dn, dp)
for l in log: print(f" {l}")
html = generate_html(neu, wieder, vorb, dn2, dp2, mode, sections=sections)
DATA_DIR.mkdir(parents=True, exist_ok=True)
with open(OUTPUT_FILE, "w", encoding="utf-8") as f: f.write(html)
print(f"\n📄 {OUTPUT_FILE}")
print(f"📦 {len(neu)} Neuheiten | ♻️ {len(wieder)} wieder da | 🔮 {len(vorb)} Vorbestellungen\n✨ Fertig!")
else:
print(f"🚀 Fantasywelt Tracker → http://localhost:{port}\n Strg+C zum Beenden\n")
server = HTTPServer(("0.0.0.0", port), Handler)
try: server.serve_forever()
except KeyboardInterrupt: print("\n👋 Beendet."); server.server_close()
if __name__ == "__main__":
main()