#!/usr/bin/env python3 """Spiele-Offensive.de Deal-Scraper v2 Scraped Startseiten-Slider, Gruppendeals und Preisaktionen. """ import re import sys import urllib.request from datetime import datetime from html import escape, unescape USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36" BASE_URL = "https://www.spiele-offensive.de" NOW = datetime.now().strftime("%d.%m.%Y %H:%M") def fetch(url, timeout=15): req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT}) try: with urllib.request.urlopen(req, timeout=timeout) as resp: raw = resp.read() try: return raw.decode("utf-8") except UnicodeDecodeError: return raw.decode("iso-8859-1") except Exception as e: print(f" [WARN] {e}", file=sys.stderr) return "" def clean_name(s): """Fix encoding artifacts in names.""" s = s.replace("ü", "ü").replace("ö", "ö").replace("ä", "ä").replace("ß", "ß") s = s.replace("Ã-", "Ü").replace("Ä", "Ä").replace("Ö", "Ö") s = s.replace("é", "é").replace("è", "è") s = s.replace("&", "&").replace(""", '"') return unescape(s).strip() def parse_slider_deals(html): """Parse deals from the start page slider.""" deals = [] # Find all unique slider entries by their onclick handler # Each entry has: registerPromotionSelection(dataLayer, promoId, `name`, `creative`, artId, `artName`, price) # with backticks in inline HTML attributes pattern = r"registerPromotionSelection\(dataLayer,\s*(\d+),\s*`([^`]*)`,\s*`([^`]*)`,\s*(\d+),\s*`([^`]+)`,\s*([\d.]+)\)" seen_promo = set() for m in re.finditer(pattern, html): promo_id = m.group(1) art_id = m.group(4) art_name = clean_name(m.group(5)) new_price = m.group(6).replace(".", ",") if promo_id in seen_promo: continue seen_promo.add(promo_id) # Find context around this match (the surrounding slider LI) ctx_start = max(0, html.rfind("
  • XX,XX €" old_match = re.search(r"statt\s*]*text-decoration:\s*line-through[^>]*>\s*(\d+,\d+)", ctx) # Extract new price from the "nur" section (split across spans) nur_match = re.search(r"nur\s*]*>\s*(\d+),]*>\s*(\d+)\s*€", ctx) if nur_match: new_price = f"{nur_match.group(1)},{nur_match.group(2)}" else: # Simpler pattern nur_match2 = re.search(r"nur\s*<[^>]*>\s*(\d+,\d+)\s*€", ctx) if nur_match2: new_price = nur_match2.group(1) # Extract discount rabatt_match = re.search(r"(\d+)\s*%\s*Rabatt", ctx) rabatt = int(rabatt_match.group(1)) if rabatt_match else None # Extract end date date_match = re.search(r"Nur bis (\d+\.\d+\.\d+)", ctx) end_date = date_match.group(1) if date_match else None # Extract stock stock_match = re.search(r"nur (\d+)\s*Stück", ctx) stock = int(stock_match.group(1)) if stock_match else None old_price = old_match.group(1) if old_match else None if old_price and new_price: # Calculate discount if not explicitly stated if rabatt is None: try: old_f = float(old_price.replace(",", ".")) new_f = float(new_price.replace(",", ".")) if old_f > 0: rabatt = round((1 - new_f / old_f) * 100) except: pass deals.append({ "name": art_name, "art_id": art_id, "old_price": old_price, "new_price": new_price, "rabatt_pct": rabatt, "end_date": end_date, "stock": stock, "url": f"/Spiel/{art_id}", "source": "Startseite", }) return deals def parse_gruppendeal(html, grid_id): """Parse a single Gruppendeal page. The page has multiple price tiers like: Preis: nur 20,00 €* statt 39,00 €* (UVP des Herstellers) Rabatt: 48,7% Ersparnis: 19,00 € We extract each tier and pick the cheapest. """ deals = [] # Get product name from H1 h1_match = re.search(r"]*>(.*?)", html, re.DOTALL) if not h1_match: return deals name = clean_name(re.sub(r"<[^>]+>", "", h1_match.group(1))) # Find article ID art_id_match = re.search(r"artikel_anzeigen&(?:amp;)?aid=([^\"']+)", html) art_id = art_id_match.group(1) if art_id_match else None # Find all "nur X €* statt Y €* (UVP)" patterns # The prices are often split across HTML spans: "nur 29,00 €*" # Strategy: find "nur" sections, strip inline tags to get clean text, then regex tiers = [] for m in re.finditer(r"nur\s+<", html): # Grab a chunk from "nur" through the UVP closing paren chunk = html[m.start() : m.start() + 400] # Strip HTML tags to get clean text text = re.sub(r"<[^>]+>", "", chunk) text = re.sub(r"\s+", " ", text).strip() # Now parse: "nur 29,00 €* statt 50,00 €* (UVP des Herstellers)" match = re.search( r"nur\s+(\d+,\d+)\s*€\s*\*\s*statt\s+(\d+,\d+)\s*€\s*\*\s*\(UVP", text, ) if match and (match.group(1), match.group(2)) not in tiers: tiers.append((match.group(1), match.group(2))) # Rabatt percentages (these are also split across spans) rabatt_raw = re.findall(r"Rabatt:\s*<[^>]*>\s*(\d+)[,.](\d)", html) rabatte = [int(r[0]) for r in rabatt_raw] # e.g. 42 from 42,0% for i, (new_p, old_p) in enumerate(tiers): rabatt = None if i < len(rabatte): rabatt = rabatte[i] deals.append({ "name": name, "art_id": art_id, "old_price": old_p, "new_price": new_p, "rabatt_pct": rabatt, "end_date": None, "stock": None, "url": f"/index.php?cmd=gruppendeal&grid={grid_id}", "source": "Gruppendeal", }) # If we have multiple tiers, keep only the cheapest if len(deals) > 1: deals.sort(key=lambda d: float(d["new_price"].replace(",", "."))) deals = deals[:1] return deals def parse_preisaktion(html, aktion_id): """Parse a Preisaktion page.""" deals = [] pattern = r"registerItemSelection\(dataLayer,\s*(\d+),\s*`([^`]+)`,\s*([\d.]+)\)" seen_ids = set() for m in re.finditer(pattern, html): art_id, art_name, price = m.groups() if art_id in seen_ids: continue seen_ids.add(art_id) # Find URL ctx_start = max(0, m.start() - 600) ctx = html[ctx_start:m.end() + 200] url_match = re.search(r'Spiel/([^\"\']+-\d+\.html)', ctx) url = f"/Spiel/{url_match.group(1)}" if url_match else None deals.append({ "name": clean_name(art_name), "art_id": art_id, "old_price": None, "new_price": price.replace(".", ","), "rabatt_pct": None, "end_date": None, "stock": None, "url": url, "source": f"Preisaktion {aktion_id}", }) return deals def fetch_article_old_price(html): """Try to find old/UVP price from an article page.""" # Find UVP or strikethrough price uvp_match = re.search(r"(?:UVP|unverbindliche\s*Preisempfehlung)[^<]*]*>\s*(\d+,\d+)\s*(?:€|€)", html, re.IGNORECASE) if uvp_match: return uvp_match.group(1) # Find strikethrough price strike_match = re.search(r"text-decoration:\s*line-through[^>]*>\s*(\d+,\d+)\s*(?:€|€)", html) if strike_match: return strike_match.group(1) return None def parse_sonderangebote(html, page=0): """Parse the Sonderangebote (special offers) page. Each item has: - -80% (discount percentage) - registerItemSelection(dataLayer, artId, `name`, price) - nur X,XX € (new price split across spans) """ deals = [] # Split into list items items = re.split(r'
  • ', html)[1:] # Skip first split for item in items: # Discount discount_match = re.search(r'class="discount">\s*-(\d+)%', item) discount = int(discount_match.group(1)) if discount_match else None # registerItemSelection reg_match = re.search(r"registerItemSelection\(dataLayer,\s*(\d+),\s*`([^`]+)`,\s*([\d.]+)\)", item) if not reg_match: continue art_id, art_name, price = reg_match.groups() art_name = clean_name(art_name) new_price = price.replace(".", ",") # URL url_match = re.search(r'Spiel/([^\"\']+-\d+\.html)', item) url = f"/Spiel/{url_match.group(1)}" if url_match else None # Calculate old price from discount old_price = None if discount and discount > 0: try: new_f = float(price) old_f = new_f / (1 - discount / 100) old_price = f"{old_f:.2f}".replace(".", ",") except: pass deals.append({ "name": art_name, "art_id": art_id, "old_price": old_price, "new_price": new_price, "rabatt_pct": discount, "end_date": None, "stock": None, "url": url, "source": "Sonderangebot", }) return deals def discover_ids(html): """Discover all grid and aktion IDs from HTML.""" grids = set() aktions = set() for m in re.finditer(r"cmd=gruppendeal&(?:amp;)?grid=(\d+)", html): grids.add(m.group(1)) for m in re.finditer(r"cmd=preisaktion&(?:amp;)?id=(\d+)", html): aktions.add(m.group(1)) return sorted(grids, key=int), sorted(aktions, key=int) def generate_html(deals, output_path): """Generate styled HTML page.""" deals_sorted = sorted(deals, key=lambda d: d.get("rabatt_pct") or 0, reverse=True) html_parts = [f""" Spiele-Offensive.de Deals – {NOW}

    🎲 Spiele-Offensive.de – Aktuelle Deals

    Stand: {NOW} – Automatisch aktualisiert

    {len(deals)}
    Angebote
    """] if deals_sorted: max_rab = max((d.get("rabatt_pct") or 0) for d in deals_sorted) sum_price = sum(float(d["new_price"].replace(",", ".")) for d in deals_sorted) html_parts.append(f'
    {max_rab}%
    Max-Rabatt
    ') html_parts.append(f'
    {sum_price/len(deals_sorted):.0f} €
    Ø-Preis
    ') html_parts.append('
    ') for d in deals_sorted: name = escape(d.get("name", "?")) old_price = d.get("old_price", "") new_price = d.get("new_price", "") rabatt = d.get("rabatt_pct") stock = d.get("stock") source = d.get("source", "") art_id = d.get("art_id", "") if art_id: full_url = f"{BASE_URL}/index.php?cmd=artikel_anzeigen&aid={art_id}&pid=505" elif d.get("url"): full_url = f"{BASE_URL}{d['url']}" full_url = f"{full_url}&pid=505" if "?" in full_url else f"{full_url}?pid=505" else: full_url = f"{BASE_URL}?pid=505" bg = "#e63946" if rabatt and rabatt >= 50: bg = "#e63946" elif rabatt and rabatt >= 30: bg = "#f39c12" elif rabatt: bg = "#3498db" html_parts.append(f'
    {name}
    ') if rabatt: html_parts.append(f'
    -{rabatt}%
    ') html_parts.append('
    ') if old_price: html_parts.append(f'{old_price} €') html_parts.append(f'{new_price} €') html_parts.append('
    ') html_parts.append(f'{escape(source)}') if stock is not None and stock > 0: html_parts.append(f'📦 {stock} Stück') if d.get("end_date"): html_parts.append(f'⏰ bis {d["end_date"]}') html_parts.append(f'
    ') html_parts.append(f'
    ') with open(output_path, "w", encoding="utf-8") as f: f.write("".join(html_parts)) print(f"\nHTML written to {output_path}") def main(): html_output = "/home/hermes/workspace/spiele_offensive_deals.html" all_deals = [] # 1. Homepage slider print("=== Startseite ===") home_html = fetch(BASE_URL, timeout=15) slider = parse_slider_deals(home_html) print(f"Slider-Deals: {len(slider)}") for d in slider: print(f" {d['new_price']}€ (-{d.get('rabatt_pct','?')}%) | {d['name'][:50]}") all_deals.extend(slider) # Discover IDs grids, aktions = discover_ids(home_html) print(f"\nGruppendeal-Grids: {grids}") print(f"Preisaktion-IDs: {aktions}") # 2. Gruppendeals for gid in grids: print(f"\n--- Gruppendeal Grid {gid} ---") html = fetch(f"{BASE_URL}/index.php?cmd=gruppendeal&grid={gid}", timeout=15) if html: deals = parse_gruppendeal(html, gid) for d in deals: print(f" {d['new_price']}€ (-{d.get('rabatt_pct','?')}%) | {d['name'][:60]}") all_deals.extend(deals) # 3. Preisaktionen for aid in aktions: print(f"\n--- Preisaktion {aid} ---") html = fetch(f"{BASE_URL}/index.php?cmd=preisaktion&id={aid}&anz=100", timeout=15) if html: deals = parse_preisaktion(html, aid) print(f" {len(deals)} Artikel (Preise ohne UVP)") # Try to get old prices for first 10 for d in deals[:10]: if d["art_id"] and d.get("url"): art_url = f"{BASE_URL}{d['url']}" art_html = fetch(art_url, timeout=10) if art_html: old = fetch_article_old_price(art_html) if old: d["old_price"] = old try: new_f = float(d["new_price"].replace(",", ".")) old_f = float(old.replace(",", ".")) if old_f > 0: d["rabatt_pct"] = round((1 - new_f / old_f) * 100) except: pass all_deals.extend(deals) # 4. Sonderangebote (paginated) print(f"\n--- Sonderangebote ---") sonderangebote_all = [] max_pages = 10 # Safety limit for page in range(max_pages): url = f"{BASE_URL}/Kat/Sonderangebote.html?sse={page}" html = fetch(url, timeout=15) if not html: break deals = parse_sonderangebote(html, page) if not deals: break print(f" Page {page}: {len(deals)} Artikel") sonderangebote_all.extend(deals) # Check if there's a next page by looking for sse={page+1} link if f"sse={page+1}" not in html: break print(f" Total Sonderangebote: {len(sonderangebote_all)}") all_deals.extend(sonderangebote_all) # Deduplicate: merge slider + Gruppendeal for same game; prefer Gruppendeal data # Also merge Preisaktion duplicates by_name = {} # normalized name -> best deal preisaktion_items = [] # keep separate (no old prices) # First pass: process Gruppendeal + Slider + Sonderangebote for d in all_deals: src = d.get("source", "") if "Preisaktion" in src and "Sonderangebot" not in src: preisaktion_items.append(d) continue norm = d.get("name", "").lower().strip() if norm not in by_name: by_name[norm] = d else: existing = by_name[norm] # Prefer deals with better discounts; Gruppendeal usually better new_rab = d.get("rabatt_pct") or 0 old_rab = existing.get("rabatt_pct") or 0 is_gruppendeal = "Gruppendeal" in d.get("source", "") is_existing_gd = "Gruppendeal" in existing.get("source", "") if is_gruppendeal and not is_existing_gd: by_name[norm] = d elif new_rab > old_rab: by_name[norm] = d # Filter out non-deals: slider entries with <5% discount and no stock info unique = [] for d in by_name.values(): rab = d.get("rabatt_pct") or 0 if rab < 5 and d.get("stock") is None: continue # Not a real deal unique.append(d) # Add Preisaktion items (dedup by art_id) seen_pa = set() for d in preisaktion_items: key = d.get("art_id", d.get("name", "")) if key not in seen_pa: seen_pa.add(key) unique.append(d) print(f"\n{'='*60}") print(f"Total unique deals: {len(unique)}") for d in unique: rab = f" (-{d.get('rabatt_pct')}%)" if d.get('rabatt_pct') else "" old = f" (statt {d.get('old_price')}€)" if d.get('old_price') else "" print(f" {d['new_price']}€{rab}{old} | {d['name'][:60]} | {d.get('source','')}") generate_html(unique, html_output) return unique if __name__ == "__main__": main()