', html_content)
for item in items[1:]: # skip header
# Extract name
name_m = re.search(r'
', item)
if not name_m:
continue
name = html.unescape(name_m.group(1).strip())
# Extract price
price_m = re.search(r'
', 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'
', html_content)
for item in items[1:]:
# Extract name
name_m = re.search(r'
(.*?)
', item)
if not name_m:
continue
name = html.unescape(name_m.group(1).strip())
# Extract new price
new_m = re.search(r'
([\d.,]+)\s*€', item)
if not new_m:
continue
price = float(new_m.group(1).replace(",", "."))
# Extract old price
old_m = re.search(r'
([\d.,]+)\s*€', 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'
]*style="[^"]*">(.*?)', section, re.DOTALL)
# Also extract prices
price_blocks = re.findall(
r'
]*style="[^"]*">(.*?).*?
([\d.,]+)\s*€',
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()