Files
BSN-Chatsystem/extract_deals.py
T

365 lines
14 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
"""Extract board game deals from 3 sources, verify on brettspielpreise.de, output JSON."""
import re
import json
import urllib.request
import urllib.parse
import urllib.error
import html
import time
import sys
import statistics
def fetch_brettspielpreise(name):
"""Search brettspielpreise.de for a game and return best price + median by scraping HTML."""
try:
data = urllib.parse.urlencode({"search": name}).encode("utf-8")
req = urllib.request.Request(
"https://brettspielpreise.de/item/search",
data=data,
headers={
"User-Agent": "Mozilla/5.0 (compatible; HermesAgent/1.0)",
"Content-Type": "application/x-www-form-urlencoded",
"Accept": "text/html",
}
)
with urllib.request.urlopen(req, timeout=15) as resp:
body = resp.read().decode("utf-8")
except Exception as e:
print(f" [WARN] HTTP error for '{name}': {e}", file=sys.stderr)
return None, None
prices_all = []
# Split by result rows (each row starts with itemimg div)
result_blocks = re.split(r'<div class="searchcell itemimg', body)
for block in result_blocks[1:]: # skip header
# Only process DE version results
if 'alt="DE"' not in block and '/img/svg/flags/DE.svg' not in block:
continue
# Extract all prices from this DE result block
# Context: up to 800 chars after the itemimg should contain the prices
context = block[:800]
price_matches = re.findall(r'<span class="price">&euro;([\d.,]+)</span>', context)
for p in price_matches:
try:
prices_all.append(float(p.replace(",", ".")))
except ValueError:
pass
if not prices_all:
return None, None
best = min(prices_all)
median = statistics.median(prices_all) if len(prices_all) >= 2 else best
return round(best, 2), round(median, 2)
def parse_amazon(html_content):
"""Parse Amazon deals HTML."""
deals = []
# Split by deal items
items = re.split(r'<div style="display:flex;gap:1rem;padding:1rem 0;border-bottom:1px solid #eee;">', html_content)
for item in items[1:]: # skip header
# Extract name
name_m = re.search(r'<strong>(.*?)</strong>', item)
if not name_m:
continue
name = html.unescape(name_m.group(1).strip())
# Extract price
price_m = re.search(r'<span style="color:#b12704;font-size:1\.2rem;font-weight:bold;">([\d.,]+)\s*€</span>', item)
if not price_m:
continue
price = float(price_m.group(1).replace(",", "."))
# Extract UVP
uvp_m = re.search(r'UVP\s*([\d.,]+)\s*€', item)
uvp = float(uvp_m.group(1).replace(",", ".")) if uvp_m else price
# Extract savings percent
savings_m = re.search(r'-(\d+)%', item)
savings_pct = int(savings_m.group(1)) if savings_m else 0
# Extract URL
url_m = re.search(r'href="(https://www\.amazon\.de/dp/[^"?]+)', item)
url = url_m.group(1) if url_m else ""
deals.append({
"name": name,
"shop": "Amazon",
"price": price,
"uvp": uvp,
"savings_pct": savings_pct,
"url": url
})
return deals
def parse_spiele_offensive(html_content):
"""Parse Spiele-Offensive deals HTML."""
deals = []
items = re.split(r'<div class="card">', html_content)
for item in items[1:]:
# Extract name
name_m = re.search(r'<div class="card-name">(.*?)</div>', item)
if not name_m:
continue
name = html.unescape(name_m.group(1).strip())
# Extract new price
new_m = re.search(r'<span class="card-new">([\d.,]+)\s*€</span>', item)
if not new_m:
continue
price = float(new_m.group(1).replace(",", "."))
# Extract old price
old_m = re.search(r'<span class="card-old">([\d.,]+)\s*€</span>', item)
uvp = float(old_m.group(1).replace(",", ".")) if old_m else price
# Extract savings percent
pct_m = re.search(r'-(\d+)%', item)
savings_pct = int(pct_m.group(1)) if pct_m else 0
# Extract URL
url_m = re.search(r'href="(https://www\.spiele-offensive\.de/[^"]+)"', item)
url = url_m.group(1) if url_m else ""
deals.append({
"name": name,
"shop": "Spiele-Offensive",
"price": price,
"uvp": uvp,
"savings_pct": savings_pct,
"url": url
})
return deals
def parse_fantasywelt(html_content):
"""Parse Fantasywelt neuheiten HTML - extract Neu im Shop and Wieder auf Lager."""
deals = []
# Parse "Neu im Shop" section
neu_section = html_content.split("Neu im Shop")[1].split("Wieder auf Lager")[0] if "Neu im Shop" in html_content and "Wieder auf Lager" in html_content else ""
# Parse "Wieder auf Lager" section
lager_section = html_content.split("Wieder auf Lager")[1] if "Wieder auf Lager" in html_content else ""
for section in [neu_section, lager_section]:
if not section:
continue
# Find all deal blocks
blocks = re.findall(r'<a href="(https://www\.fantasywelt\.de/[^"?]+)[^"]*"[^>]*style="[^"]*">(.*?)</a>', section, re.DOTALL)
# Also extract prices
price_blocks = re.findall(
r'<a href="(https://www\.fantasywelt\.de/[^"?]+)[^"]*"[^>]*style="[^"]*">(.*?)</a>.*?<span style="color:#(?:e8952e|2563eb);font-size:1\.1rem;font-weight:700;">([\d.,]+)\s*€</span>',
section, re.DOTALL
)
for match in price_blocks:
url, name_raw, price_str = match
name = html.unescape(name_raw.strip())
try:
price = float(price_str.replace(",", "."))
except ValueError:
continue
deals.append({
"name": name,
"shop": "Fantasywelt",
"price": price,
"uvp": price, # No UVP given
"savings_pct": 0,
"url": url
})
return deals
def is_board_game(name):
"""Filter out non-boardgame items."""
name_lower = name.lower()
# Exclude these categories - pure card decks, accessories, etc.
exclude_keywords = [
"quartett", "top trumps", "top ass", "schwarzer peter",
"rommé", "romme", "bridge canasta", "canasta",
"skat", "skatkarten", "spielkarten klassiker",
"leiter spiel", "leiterspiel", "metalldose",
"deluxe holz", # chess
"lernkartenspiel",
"krimi-dinner",
"hidden games",
"mord bei tisch",
"miniatures pack", "game mat",
"wundertüte", "jubiläumswundertüte",
"erweiterung]", "expansion (de)", # pure expansions
"logik rätsel", "rätsel", # puzzle books
"metalldose", # travel versions in tin boxes
]
for kw in exclude_keywords:
if kw in name_lower:
return False
# Known good games that should always be included
known_good = [
"my city", "catan", "evacuation", "leben in reterra",
"das verrückte labyrinth", "funkelschatz", "monopoly",
"jumanji", "die verräter", "scrabble",
"sagaland", "outsmarted", "die magischen schlüssel",
"stille post extrem", "maus reiß aus",
"intrepid", "challengers", "next station",
"27", "hitster", "the mind",
"root", "zug um zug", "dixit",
"king of tokyo", "karak", "paleo",
"stone age", "robinson crusoe", "sos dino",
"t.i.m.e. stories", "pax pamir", "obsession",
"marvel remix", "robot quest", "flick of faith",
"unmatched", "neuroshima", "fantastic factories",
"kitchen rush", "pagan", "wasserkraft",
"perdition", "katapult fehde", "fika",
"zank am zaun", "onirim", "eleven",
"schätz it", "der herr der ringe", "dungeon fighter",
"die verdrehte spuknacht", "mythwind", "ein sommernachtstraum",
"punkte salat", "4 bilder 1 wort",
"grand conquest", "tribe", "hijacked", "influentia",
]
for good in known_good:
if good in name_lower:
return True
# General board game indicators
include_keywords = [
"brettspiel", "gesellschaft", "familien",
"strategie", "fantasy", "adventure",
"kartenspiel", "würfelspiel",
]
for kw in include_keywords:
if kw in name_lower:
return True
return False
def clean_display_name(name):
"""Clean a name for display in output JSON."""
# Remove HTML entities
name = html.unescape(name)
name = re.sub(r'\s*\(DE\)\s*', '', name)
name = re.sub(r'\s*\((?:EN|Multi|Multilingual)\)\s*', '', name, flags=re.IGNORECASE)
name = re.sub(r'\s*\[.*?\]\s*', '', name)
# Truncate at comma or dash followed by description words
name = re.sub(r'\s*[,-]\s*(?:Brettspiel|Kartenspiel|Gesellschaftsspiel|Familienspiel|Kinderspiel|Mitbringspiel|Würfelspiel|Aktionsspiel|Strategiespiel|Party.spiel).*$', '', name, flags=re.IGNORECASE)
name = re.sub(r'\s*(?:für|für|ab)\s+\d+.*?(?:Spieler|Personen|Jahren?).*$', '', name, flags=re.IGNORECASE)
name = re.sub(r'\s*-\s*(?:Deutsche (?:Version|Fassung)).*$', '', name, flags=re.IGNORECASE)
name = re.sub(r'\s*-\s*(?:Kennerspiel des Jahres \d+).*$', '', name, flags=re.IGNORECASE)
name = re.sub(r'\s*-\s*(?:Nominiert Spiel des Jahres \d+).*$', '', name, flags=re.IGNORECASE)
# Remove trailing punctuation
name = re.sub(r'\s*[,;:-]\s*$', '', name)
name = re.sub(r'\s+', ' ', name).strip()
if len(name) > 100:
name = name[:97] + "..."
return name
def clean_name(name):
"""Clean up a game name for searching on brettspielpreise.de."""
# Remove HTML entities
name = html.unescape(name)
# Remove parentheticals with extensions/language codes
name = re.sub(r'\s*\(DE\)\s*', '', name)
name = re.sub(r'\s*\((?:EN|Multi|Multilingual)\)\s*', '', name, flags=re.IGNORECASE)
name = re.sub(r'\s*\[.*?\]\s*', '', name)
# Remove common suffixes
name = re.sub(r'\s*[,-]\s*(?:Brettspiel|Kartenspiel|Gesellschaftsspiel|Familienspiel|Kinderspiel|Mitbringspiel|Würfelspiel|Aktionsspiel|Strategiespiel|Party.spiel).*$', '', name, flags=re.IGNORECASE)
# Remove "für X Spieler" etc
name = re.sub(r'\s*(?:für|für|ab)\s+\d+.*?(?:Spieler|Personen|Jahren?).*$', '', name, flags=re.IGNORECASE)
name = re.sub(r'\s*-\s*(?:Deutsche (?:Version|Fassung)|Deutsche Fassung|Deutsche Version).*$', '', name, flags=re.IGNORECASE)
# Remove publisher prefixes like "KOSMOS 684655 ", "Schmidt Spiele 49201 ", etc.
name = re.sub(r'^(?:KOSMOS|Schmidt Spiele|Ravensburger|Mattel Games|Game Factory|HABA|Hasbro Gaming|Goliath|Spin Master Games|NSV|MATTEL GAMES)\s+\d*\s*', '', name, flags=re.IGNORECASE)
# Clean up
name = re.sub(r'\s+', ' ', name).strip()
name = name[:80].strip()
return name
def main():
# Load HTML files
with open("/home/hermes/workspace/amazon_deals_2026-06-05.html", "r") as f:
amazon_html = f.read()
with open("/home/hermes/workspace/spiele_offensive_deals.html", "r") as f:
so_html = f.read()
with open("/home/hermes/workspace/fantasywelt_data/neuheiten.html", "r") as f:
fw_html = f.read()
# Parse deals
amazon_deals = parse_amazon(amazon_html)
so_deals = parse_spiele_offensive(so_html)
fw_deals = parse_fantasywelt(fw_html)
print(f"Extracted: {len(amazon_deals)} Amazon, {len(so_deals)} Spiele-Offensive, {len(fw_deals)} Fantasywelt")
# Combine and filter
all_deals = amazon_deals + so_deals + fw_deals
# Filter for board games
filtered = [d for d in all_deals if is_board_game(d["name"])]
print(f"After filtering: {len(filtered)} deals")
# Sort by savings_pct descending, take top 30 to check
filtered.sort(key=lambda x: x["savings_pct"], reverse=True)
top_candidates = filtered[:30]
# Verify prices on brettspielpreise.de
verified = []
for i, deal in enumerate(top_candidates):
search_name = clean_name(deal["name"])
print(f"[{i+1}/{len(top_candidates)}] Checking: {search_name}")
best_other, median_market = fetch_brettspielpreise(search_name)
# Determine verdict
if best_other is not None:
if deal["price"] <= best_other:
verdict = "gut"
elif deal["price"] <= best_other * 1.15:
verdict = "durchschnittlich"
else:
verdict = "schlecht"
else:
verdict = "unbekannt"
verified.append({
"name": clean_display_name(deal["name"]),
"shop": deal["shop"],
"price": deal["price"],
"uvp": deal["uvp"],
"savings_pct": deal["savings_pct"],
"url": deal["url"],
"best_other_price": best_other,
"median_market_price": median_market,
"verdict": verdict
})
# Rate limit
time.sleep(1)
# Sort final by savings_pct, take top 12
verified.sort(key=lambda x: x["savings_pct"], reverse=True)
top12 = verified[:12]
# Write JSON
with open("/tmp/deals_verified.json", "w") as f:
json.dump(top12, f, indent=2, ensure_ascii=False)
print(f"\nDone! Wrote {len(top12)} deals to /tmp/deals_verified.json")
for d in top12:
print(f" {d['savings_pct']:3d}% | {d['shop']:20s} | {d['price']:6.2f}€ | {d['name'][:60]}")
if __name__ == "__main__":
main()