Files
BSN-Chatsystem/spiele_offensive_deals.py
T

533 lines
21 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
"""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("<li ", 0, m.start()))
if ctx_start < 0:
ctx_start = max(0, m.start() - 3000)
ctx_end = min(len(html), m.end() + 3000)
ctx = html[ctx_start:ctx_end]
# Extract old price: "statt <span ...>XX,XX €</span>"
old_match = re.search(r"statt\s*<span[^>]*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*<span[^>]*>\s*(\d+)</span>,<span[^>]*>\s*(\d+)</span>\s*&euro;", 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*&euro;", 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"<h1[^>]*>(.*?)</h1>", 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 <span>29,</span><span>00</span> &euro;*"
# 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 &euro;* statt 50,00 &euro;* (UVP des Herstellers)"
match = re.search(
r"nur\s+(\d+,\d+)\s*&euro;\s*\*\s*statt\s+(\d+,\d+)\s*&euro;\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*(?:&euro;|€)", 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*(?:&euro;|€)", 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:
- <span class="discount">-<span>80</span>%</span> (discount percentage)
- registerItemSelection(dataLayer, artId, `name`, price)
- nur <span>X</span>,<span>XX</span> &euro; (new price split across spans)
"""
deals = []
# Split into list items
items = re.split(r'<li class="ala">', html)[1:] # Skip first split
for item in items:
# Discount
discount_match = re.search(r'class="discount">\s*-<span>(\d+)</span>%', 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"""<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Spiele-Offensive.de Deals {NOW}</title>
<style>
* {{ margin:0; padding:0; box-sizing:border-box; }}
body {{ font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif; background:#f0f2f5; color:#1a1a2e; padding:20px; }}
.header {{ background:linear-gradient(135deg,#e63946,#c1121f); color:white; padding:30px; border-radius:12px; margin-bottom:24px; text-align:center; }}
.header h1 {{ font-size:28px; margin-bottom:6px; }}
.header p {{ font-size:14px; opacity:0.9; }}
.stats {{ display:flex; gap:16px; justify-content:center; margin-bottom:24px; flex-wrap:wrap; }}
.stat {{ background:white; padding:14px 24px; border-radius:10px; box-shadow:0 2px 8px rgba(0,0,0,0.08); text-align:center; }}
.stat .num {{ font-size:26px; font-weight:700; color:#e63946; }}
.stat .label {{ font-size:12px; color:#666; margin-top:2px; }}
.grid {{ display:grid; grid-template-columns:repeat(auto-fill,minmax(340px,1fr)); gap:16px; }}
.card {{ background:white; border-radius:12px; padding:20px; box-shadow:0 2px 12px rgba(0,0,0,0.06); transition:transform 0.15s,box-shadow 0.15s; border-left:4px solid #e63946; display:flex; flex-direction:column; }}
.card:hover {{ transform:translateY(-2px); box-shadow:0 6px 20px rgba(0,0,0,0.12); }}
.card-top {{ display:flex; justify-content:space-between; align-items:flex-start; gap:12px; }}
.card-name {{ font-size:16px; font-weight:600; color:#1a1a2e; line-height:1.3; flex:1; }}
.card-badge {{ padding:4px 10px; border-radius:20px; font-size:13px; font-weight:700; white-space:nowrap; color:white; }}
.card-prices {{ margin-top:14px; display:flex; align-items:baseline; gap:12px; }}
.card-old {{ font-size:15px; color:#999; text-decoration:line-through; }}
.card-new {{ font-size:24px; font-weight:700; color:#e63946; }}
.card-bottom {{ margin-top:14px; font-size:12px; color:#888; display:flex; gap:12px; flex-wrap:wrap; }}
.card-link {{ margin-top:auto; padding-top:14px; }}
.card-link a {{ display:inline-block; background:#e63946; color:white; padding:8px 18px; border-radius:6px; text-decoration:none; font-size:13px; font-weight:500; }}
.card-link a:hover {{ background:#c1121f; }}
.source-tag {{ font-size:11px; background:#f0f0f0; padding:2px 8px; border-radius:4px; color:#666; }}
.footer {{ margin-top:30px; text-align:center; font-size:11px; color:#999; }}
</style>
</head>
<body>
<div class="header">
<h1>🎲 Spiele-Offensive.de Aktuelle Deals</h1>
<p>Stand: {NOW} Automatisch aktualisiert</p>
</div>
<div class="stats">
<div class="stat"><div class="num">{len(deals)}</div><div class="label">Angebote</div></div>
"""]
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'<div class="stat"><div class="num">{max_rab}%</div><div class="label">Max-Rabatt</div></div>')
html_parts.append(f'<div class="stat"><div class="num">{sum_price/len(deals_sorted):.0f} €</div><div class="label">Ø-Preis</div></div>')
html_parts.append('</div><div class="grid">')
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'<div class="card"><div class="card-top"><div class="card-name">{name}</div>')
if rabatt:
html_parts.append(f'<div class="card-badge" style="background:{bg}">-{rabatt}%</div>')
html_parts.append('</div><div class="card-prices">')
if old_price:
html_parts.append(f'<span class="card-old">{old_price} €</span>')
html_parts.append(f'<span class="card-new">{new_price} €</span>')
html_parts.append('</div><div class="card-bottom">')
html_parts.append(f'<span class="source-tag">{escape(source)}</span>')
if stock is not None and stock > 0:
html_parts.append(f'<span>📦 {stock} Stück</span>')
if d.get("end_date"):
html_parts.append(f'<span>⏰ bis {d["end_date"]}</span>')
html_parts.append(f'</div><div class="card-link"><a href="{full_url}" target="_blank">Zum Angebot →</a></div></div>')
html_parts.append(f'</div><div class="footer"><p>brettspiel-news.de Spiele-Offensive Deal-Scraper {NOW}</p></div></body></html>')
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()