508 lines
26 KiB
Python
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()
|