fix: QC-Ratings aus 7 älteren Artikeln entfernt (CHECK 10)
This commit is contained in:
+275
@@ -0,0 +1,275 @@
|
||||
#!/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!)")
|
||||
Reference in New Issue
Block a user