#!/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}
"""]
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'
')
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()