Files

276 lines
9.9 KiB
Python
Raw Permalink 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
"""Fetch Amazon board game deals and compare with brettspielpreise.de price histories.
Final version with validated matches."""
import re, json, time, urllib.request, urllib.parse, ssl
# ---- Step 1: Parse HTML ----
html_path = "/home/hermes/workspace/amazon_deals_2026-06-05.html"
with open(html_path, "r", encoding="utf-8") as f:
html = f.read()
deal_blocks = re.findall(
r'<div style="display:flex;gap:1rem;padding:1rem 0;border-bottom:1px solid #eee;">'
r'(.*?)</div></div>',
html, re.DOTALL
)
print(f"Found {len(deal_blocks)} deal blocks")
deals = []
for block in deal_blocks:
name_match = re.search(r'<strong>(.*?)</strong>', block)
name = name_match.group(1).strip() if name_match else "Unknown"
price_match = re.search(r'font-weight:bold;">(\d+[.,]\d+) €', block)
price = float(price_match.group(1).replace(",", ".")) if price_match else None
uvp_match = re.search(r'UVP (\d+[.,]\d+) €', block)
uvp = float(uvp_match.group(1).replace(",", ".")) if uvp_match else None
asin_match = re.search(r'/dp/([A-Z0-9]+)', block)
asin = asin_match.group(1) if asin_match else None
url_match = re.search(r'href="(https://www\.amazon\.de/dp/[^"]+)"', block)
url = url_match.group(1) if url_match else f"https://www.amazon.de/dp/{asin}?tag=60pro05-21"
deals.append({"name": name, "amazon_price": price, "uvp": uvp, "asin": asin, "url": url})
# ---- Step 2: Filter ----
FILTER_KEYWORDS = [
"schwarzer peter", "skat", "rommé", "romme", "bridge canasta",
"top trumps", "wetfussballstars",
"uno ", "uno flip", "uno show", "uno teams", "phase 10",
]
filtered = []
for d in deals:
name_lower = d["name"].lower()
if not any(kw in name_lower for kw in FILTER_KEYWORDS):
filtered.append(d)
else:
kw = next(kw for kw in FILTER_KEYWORDS if kw in name_lower)
print(f" SKIP: {d['name'][:70]}... [{kw}]")
print(f"After filtering: {len(filtered)} deals")
# ---- Step 3: HTTP helpers ----
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
def http_get(url):
req = urllib.request.Request(url, headers={
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36",
})
try:
with urllib.request.urlopen(req, timeout=15, context=ctx) as resp:
return resp.read().decode("utf-8", errors="replace")
except Exception:
return None
# ---- Search terms with specific overrides ----
SEARCH_CONFIG = [
# (amazon_name_keyword, search_term)
("Maus reiß aus", "Maus reiß aus"),
("Die Verräter", "Die Verräter Brettspiel"),
("Jumanji", "Jumanji Familienspiel"),
("Monsterjäger", "Monsterjäger Schmidt"),
("Sweet Takes", "Sweet Takes"),
("My City", "My City Kosmos"),
("Evacuation", "Evacuation"),
("STILLE POST EXTREM", "Stille Post Extrem"),
("Dog", "Dog Royal"),
("THE MIND", "The Mind"),
("Catan", "Catan"),
("Leben in Reterra", "Leben in Reterra"),
("Sagaland", "Sagaland"),
("Hitster", "Hitster"),
("Outsmarted", "Outsmarted"),
("Monopoly Harry Potter", "Monopoly Harry Potter"),
("Die Magischen Schlüssel", "Die Magischen Schlüssel"),
("Scrabble Zwei in Eins", "Scrabble"),
("Das verrückte Labyrinth", "Das verrückte Labyrinth"),
("Funkelschatz", "Funkelschatz"),
("Leiterspiel", "Leiterspiel Schmidt"),
("Ligretto", "Ligretto"),
]
FALLBACK_SEARCH = {
"Die Verräter": ["the traitors die verrater"],
"Dog": ["Dog Den letzten beissen die Hunde"],
"Sweet Takes": ["Sweet Takes Hasbro"],
"Die Magischen Schlüssel": ["Magischen Schlüssel Game Factory"],
}
def get_search_terms(name):
terms = []
for key, val in SEARCH_CONFIG:
if key.lower() in name.lower():
terms.append(val)
break
if not terms:
words = name.replace("-", " ").replace("", " ").replace(",", " ").split()
stop = {"das", "die", "der", "den", "für", "und", "mit", "von", "ein", "eine", "einen", "ab", "des", "dem"}
meaningful = [w for w in words if w.lower() not in stop and len(w) > 1 and not w.isdigit()]
terms.append(" ".join(meaningful[:3]))
for key, fbs in FALLBACK_SEARCH.items():
if key.lower() in name.lower():
for fb in fbs:
if fb not in terms:
terms.append(fb)
return terms
def search_brettspielpreise(search_term):
url = f"https://brettspielpreise.de/item/search?search={urllib.parse.quote(search_term)}"
body = http_get(url)
if not body:
return []
show_links = re.findall(r'/item/show/(\d+)/([^"]+)', body)
items, seen = [], set()
for item_id, slug in show_links:
if item_id not in seen:
seen.add(item_id)
history_url = f"https://brettspielpreise.de/item/history/{item_id}/{slug}"
items.append((item_id, urllib.parse.unquote(slug), history_url))
return items[:5]
def get_price_history(history_url):
body = http_get(history_url)
if not body:
return None
idx = body.find('datasets')
if idx == -1:
return None
s = body.rfind('<script', 0, idx)
e = body.find('</script>', idx)
if s == -1 or e == -1:
return None
script = body[s:e]
all_data, pos = [], 0
while True:
di = script.find('data: [', pos)
if di == -1:
break
start = di + 6
depth, end = 0, start
for i in range(start, len(script)):
if script[i] == '[':
depth += 1
elif script[i] == ']':
depth -= 1
if depth == 0:
end = i + 1
break
try:
pts = json.loads(script[start:end])
all_data.append(pts)
except json.JSONDecodeError:
pass
pos = end
for pts in all_data:
if pts and isinstance(pts, list) and len(pts) > 0 and isinstance(pts[0], dict) and "y" in pts[0]:
prices = []
for pt in pts:
try:
p = float(pt.get("y", 0))
if p > 0:
prices.append(p)
except (ValueError, TypeError):
pass
if len(prices) >= 3:
return prices
return None
def validate_match(current, low, high, datapoints):
"""Check if current price reasonably fits this game's history."""
if low <= 0:
return False
dist = (current - low) / low * 100
# For sparse data (<15 points), allow wider range (tracking may miss deals)
if datapoints < 15:
if dist < -65: # very sparse data could miss deals entirely
return False
else:
if dist < -35: # current shouldn't be way below tracked history
return False
if dist > 300: # way too expensive = wrong match
return False
return True
# ---- Step 4: Process ----
results = []
skipped_wrong_match = []
for i, d in enumerate(filtered):
print(f"\n[{i+1}/{len(filtered)}] {d['name'][:75]}")
print(f" Amazon: {d['amazon_price']}€ | UVP: {d['uvp']}")
search_terms = get_search_terms(d["name"])
matched = False
for term in search_terms:
if matched:
break
candidates = search_brettspielpreise(term)
if not candidates:
continue
for c_idx, (item_id, item_name, history_url) in enumerate(candidates):
prices = get_price_history(history_url)
if not prices:
continue
low, high = round(min(prices), 2), round(max(prices), 2)
current = d["amazon_price"]
if not validate_match(current, low, high, len(prices)):
dist = (current - low) / low * 100
print(f" #{c_idx+1} ID {item_id} '{item_name[:45]}': low={low}€, dist={dist:.1f}% → SKIP")
continue
dist_pct = round((current - low) / low * 100, 1)
sav_pct = round((d["uvp"] - current) / d["uvp"] * 100, 1) if d["uvp"] and d["uvp"] > 0 else 0
print(f" ✓ MATCH: '{item_name[:50]}' (ID {item_id})")
print(f" History: {len(prices)}pts, low={low}€, high={high}€, current={current}€ ({dist_pct:+.1f}%)")
results.append({
"name": d["name"],
"amazon_price": current,
"uvp": d["uvp"],
"savings_pct": sav_pct,
"history_low": low,
"history_high": high,
"distance_from_low_pct": dist_pct,
"url": d["url"],
"history_url": history_url,
"datapoints": len(prices),
})
matched = True
break
if not matched:
print(f" ✗ No valid match on brettspielpreise.de")
# Sort: closer to best price first (lowest distance_from_low_pct)
results.sort(key=lambda x: x["distance_from_low_pct"])
print(f"\n{'='*70}")
print(f"RESULTS: {len(results)} games with price histories (sorted by proximity to best price)")
print(f"{'='*70}")
for i, r in enumerate(results):
star = "" if r["distance_from_low_pct"] <= 10 else " "
print(f" {i+1:2d}. {star} {r['distance_from_low_pct']:>+6.1f}% | {r['amazon_price']:>7.2f}"
f"(low: {r['history_low']:>6.2f}€, high: {r['history_high']:>6.2f}€, {r['datapoints']}pts) "
f"| {r['name'][:50]}")
# Save JSON
output_path = "/tmp/amazon_near_bestprice.json"
with open(output_path, "w", encoding="utf-8") as f:
json.dump(results, f, ensure_ascii=False, indent=2)
print(f"\nSaved {len(results)} results to {output_path}")
# Summary stats
close = [r for r in results if r["distance_from_low_pct"] <= 15]
print(f"\n{len(close)} games within 15% of their historical best price")
print(f" {len([r for r in results if r['distance_from_low_pct'] <= 0])} games at or below historical best price (new lows!)")