Fix: Edelmetall Scraper Pro Aurum CSS Modules + Preisreihenfolge (24.02.2026)

This commit is contained in:
Auto-Sync 2026-02-24 13:09:21 +01:00
parent 3c85cc81d2
commit c3c5c8a9cc
3 changed files with 389 additions and 0 deletions

150
edelmetall/code/prices.py Normal file
View file

@ -0,0 +1,150 @@
"""Einheitliche Preis-API — eine Quelle der Wahrheit für Spot + Händler."""
import requests
import sqlite3
from typing import Optional
from .db import get_db
OZ = 31.1035 # Gramm pro Unze
def get_exchange_rate() -> float:
"""EUR/USD Kurs aus DB, Fallback 0.92."""
try:
conn = get_db()
row = conn.execute(
"SELECT rate FROM exchange_rates ORDER BY timestamp DESC LIMIT 1"
).fetchone()
conn.close()
return row["rate"] if row else 0.92
except Exception:
return 0.92
def fetch_spot() -> Optional[dict]:
"""
Holt Gold/Silber Spotpreise.
Gibt EUR und USD zurück immer beide Einheiten klar benannt.
"""
try:
r_gold = requests.get("https://api.gold-api.com/price/XAU", timeout=10)
r_silver = requests.get("https://api.gold-api.com/price/XAG", timeout=10)
gold_usd = r_gold.json().get("price", 0)
silver_usd = r_silver.json().get("price", 0)
eur_usd = get_exchange_rate()
gold_eur = gold_usd * eur_usd
silver_eur = silver_usd * eur_usd
return {
# USD pro Unze
"gold_usd_oz": gold_usd,
"silver_usd_oz": silver_usd,
# EUR pro Unze
"gold_eur_oz": gold_eur,
"silver_eur_oz": silver_eur,
# EUR pro Gramm (für Validierung + Portfolio)
"gold_eur_g": gold_eur / OZ,
"silver_eur_g": silver_eur / OZ,
# Meta
"eur_usd": eur_usd,
"ratio": gold_usd / silver_usd if silver_usd > 0 else 0,
}
except Exception as e:
return None
def save_spot(spot: dict):
"""Spotpreis in DB speichern."""
conn = get_db()
conn.execute("""
INSERT INTO spot_prices (gold_usd, silver_usd, gold_eur, silver_eur, eur_usd)
VALUES (?, ?, ?, ?, ?)
""", (
spot["gold_usd_oz"], spot["silver_usd_oz"],
spot["gold_eur_oz"], spot["silver_eur_oz"],
spot["eur_usd"],
))
conn.commit()
conn.close()
def get_latest_spot() -> Optional[dict]:
"""Letzten gespeicherten Spotpreis aus DB."""
conn = get_db()
row = conn.execute("""
SELECT gold_usd, silver_usd, gold_eur, silver_eur, eur_usd, timestamp
FROM spot_prices ORDER BY timestamp DESC LIMIT 1
""").fetchone()
conn.close()
if not row:
return None
eur_usd = row["eur_usd"] or 0.92
gold_eur_oz = row["gold_eur"] or (row["gold_usd"] * eur_usd)
silver_eur_oz = row["silver_eur"] or (row["silver_usd"] * eur_usd)
return {
"gold_usd_oz": row["gold_usd"],
"silver_usd_oz": row["silver_usd"],
"gold_eur_oz": gold_eur_oz,
"silver_eur_oz": silver_eur_oz,
"gold_eur_g": gold_eur_oz / OZ,
"silver_eur_g": silver_eur_oz / OZ,
"eur_usd": eur_usd,
"ratio": row["gold_usd"] / row["silver_usd"] if row["silver_usd"] else 0,
"timestamp": row["timestamp"],
}
def get_best_dealer_prices(hours: int = 6) -> dict:
"""
Bester Ankaufpreis für 1oz Münzen der letzten N Stunden.
Einfach, direkt, keine komplexe Filterlogik.
"""
conn = get_db()
# Gold: Krügerrand bevorzugen, dann andere Standardmünzen (Median statt Max)
gold_rows = conn.execute("""
SELECT product, buy_price, sell_price, weight_g,
COALESCE(buy_price, sell_price) / weight_g AS ppg
FROM gold_prices
WHERE weight_g BETWEEN 30 AND 32
AND COALESCE(buy_price, sell_price) > 0
AND timestamp >= datetime('now', ?)
ORDER BY ppg
""", (f"-{hours} hours",)).fetchall()
# Krügerrand bevorzugen, sonst Median
gold = next(
(r for r in gold_rows if "krügerrand" in r["product"].lower() or "kruger" in r["product"].lower()),
gold_rows[len(gold_rows)//2] if gold_rows else None
)
# Silber: Standardmünzen (Maple, Philharmoniker etc.), Median
silver_rows = conn.execute("""
SELECT product, buy_price, sell_price, weight_g,
COALESCE(buy_price, sell_price) / weight_g AS ppg
FROM silver_prices
WHERE weight_g BETWEEN 30 AND 32
AND COALESCE(buy_price, sell_price) > 0
AND timestamp >= datetime('now', ?)
ORDER BY ppg
""", (f"-{hours} hours",)).fetchall()
silver = next(
(r for r in silver_rows if any(k in r["product"].lower()
for k in ["maple", "philharmoniker", "känguru", "britannia", "kruger"])),
silver_rows[len(silver_rows)//2] if silver_rows else None
)
conn.close()
def _price(row):
if not row: return 0
return row["buy_price"] if row["buy_price"] else (row["sell_price"] if row["sell_price"] else round(row["ppg"] * 31.1035, 2))
return {
"gold_ppg": gold["ppg"] if gold else 0,
"gold_oz": _price(gold),
"gold_product": gold["product"] if gold else "",
"silver_ppg": silver["ppg"] if silver else 0,
"silver_oz": _price(silver),
"silver_product": silver["product"] if silver else "",
}

135
edelmetall/code/proaurum.py Normal file
View file

@ -0,0 +1,135 @@
"""Pro Aurum Scraper — Selenium, Stealth Mode."""
import re
import random
import time
import logging
from datetime import datetime
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.chrome.service import Service
logger = logging.getLogger(__name__)
OZ = 31.1035
GOLD_URL = "https://www.proaurum.de/shop/gold/goldmuenzen-zur-kapitalanlage/"
SILVER_URL = "https://www.proaurum.de/shop/silber/silbermuenzen/"
USER_AGENTS = [
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/121.0.0.0 Safari/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/121.0.0.0 Safari/537.36",
]
def _make_driver() -> webdriver.Chrome:
opts = Options()
opts.add_argument(f"--user-agent={random.choice(USER_AGENTS)}")
opts.add_argument("--headless=new")
opts.add_argument("--no-sandbox")
opts.add_argument("--disable-dev-shm-usage")
opts.add_argument("--disable-blink-features=AutomationControlled")
opts.add_experimental_option("excludeSwitches", ["enable-automation"])
opts.add_experimental_option("useAutomationExtension", False)
driver = webdriver.Chrome(service=Service("/bin/chromedriver"), options=opts)
driver.execute_cdp_cmd("Page.addScriptToEvaluateOnNewDocument", {
"source": "Object.defineProperty(navigator, 'webdriver', {get: () => undefined});"
})
return driver
def _parse_price(text: str) -> float | None:
cleaned = text.replace(".", "").replace(",", ".").replace("", "").strip()
try:
return float(cleaned)
except ValueError:
return None
def _weight_from_name(name: str) -> float:
n = name.lower()
if "10 kg" in n or "10 kilogramm" in n: return 10000.0
if "1 kg" in n or "kilogramm" in n: return 1000.0
if "10 unzen" in n or "10 oz" in n: return OZ * 10
if "1/2" in n: return OZ / 2
if "1/4" in n: return OZ / 4
if "1/10" in n: return OZ / 10
return OZ # Standard 1oz
def _scrape_page(driver: webdriver.Chrome, url: str) -> list[dict]:
logger.info(f"Lade {url}")
driver.get(url)
time.sleep(random.uniform(4, 7))
products = []
# Pro Aurum CSS Modules (Stand 2026-02)
# productButton-buy = "Kaufen" = Kaufpreis (sell_price, was Kunde zahlt)
# productButton-sell = "Verkaufen" = Ankaufspreis (buy_price, was Händler zahlt)
# Preise stehen in buySellSection-price: erst Kauf-, dann Ankaufspreis
cards = driver.find_elements("css selector", "[class*='product-root']")
for card in cards:
try:
# Name: Text ohne € Zeichen
name = ""
for sel in ["a", "h2", "h3", "[class*='name']"]:
els = card.find_elements("css selector", sel)
for el in els:
t = el.text.strip()
if t and len(t) > 5 and "" not in t and "Kaufen" not in t and "Verkaufen" not in t:
name = t.split("\n")[0].strip()
break
if name:
break
# Beide Preise in Reihenfolge holen
# [0] = Kaufpreis (sell_price), [1] = Ankaufspreis (buy_price)
price_els = card.find_elements("css selector", "[class*='buySellSection-price']")
sell = _parse_price(price_els[0].text) if len(price_els) > 0 else None
buy = _parse_price(price_els[1].text) if len(price_els) > 1 else None
# Fallback: alle €-Preise wenn buySellSection nicht gefunden
if not sell:
for el in card.find_elements("css selector", "[class*='price'], [class*='Price']"):
v = _parse_price(el.text)
if v and v > 100:
sell = v
break
weight = _weight_from_name(name)
if sell and sell > 0:
products.append({
"product": name,
"sell_price": sell,
"buy_price": buy,
"weight_g": weight,
})
except Exception:
continue
return products
def scrape() -> dict:
"""
Scrapt Pro Aurum. Gibt {'gold': [...], 'silver': [...]} zurück.
Wirft Exception wenn es scheitert Fallback wird vom Aufrufer gehandelt.
"""
t0 = datetime.now()
driver = _make_driver()
try:
# Zufällige Wartezeit (Anti-Bot)
wait = random.randint(60, 150)
logger.info(f"Warte {wait}s vor dem Scraping...")
time.sleep(wait)
gold = _scrape_page(driver, GOLD_URL)
silver = _scrape_page(driver, SILVER_URL)
duration = (datetime.now() - t0).total_seconds()
logger.info(f"Pro Aurum: {len(gold)} Gold, {len(silver)} Silber in {duration:.0f}s")
return {"gold": gold, "silver": silver, "source": "proaurum"}
finally:
driver.quit()

104
edelmetall/code/scrape.py Normal file
View file

@ -0,0 +1,104 @@
#!/usr/bin/env python3
"""
Haupt-Scraper: Pro Aurum primär, Degussa als Fallback.
Wird per Cron aufgerufen.
"""
import sys
import logging
from datetime import datetime
sys.path.insert(0, "/opt/edelmetall")
from core.db import get_db, init_db
from core.prices import fetch_spot, save_spot
from scrapers import proaurum, degussa
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
handlers=[
logging.FileHandler("/opt/edelmetall/logs/scraper.log"),
logging.StreamHandler(),
]
)
logger = logging.getLogger(__name__)
def save_prices(data: dict):
"""Speichert Scraper-Ergebnis in DB."""
conn = get_db()
c = conn.cursor()
source = data["source"]
for p in data["gold"]:
c.execute("""
INSERT INTO gold_prices (product, buy_price, sell_price, weight_g, source)
VALUES (?, ?, ?, ?, ?)
""", (p["product"], p.get("buy_price"), p.get("sell_price"), p["weight_g"], source))
for p in data["silver"]:
c.execute("""
INSERT INTO silver_prices (product, buy_price, sell_price, weight_g, source)
VALUES (?, ?, ?, ?, ?)
""", (p["product"], p.get("buy_price"), p.get("sell_price"), p["weight_g"], source))
conn.commit()
conn.close()
def log_run(source: str, success: bool, gold: int, silver: int, duration: float, error: str = None):
conn = get_db()
conn.execute("""
INSERT INTO scraper_log (source, success, gold_count, silver_count, duration_s, error)
VALUES (?, ?, ?, ?, ?, ?)
""", (source, int(success), gold, silver, duration, error))
conn.commit()
conn.close()
def main():
init_db()
t0 = datetime.now()
# 1. Spot-Preise holen
spot = fetch_spot()
if spot:
save_spot(spot)
logger.info(f"Spot: Gold {spot['gold_eur_oz']:.2f} EUR/oz | Silber {spot['silver_eur_oz']:.2f} EUR/oz")
else:
logger.warning("Spot-Preise nicht abrufbar")
# 2. Händlerpreise: Pro Aurum → Degussa Fallback
data = None
source = None
error = None
try:
logger.info("Versuche Pro Aurum (Selenium)...")
data = proaurum.scrape()
source = "proaurum"
logger.info(f"Pro Aurum: {len(data['gold'])} Gold, {len(data['silver'])} Silber")
if not data["gold"] and not data["silver"]:
raise ValueError("Pro Aurum: 0 Produkte — Seite geaendert, Fallback zu Degussa")
except Exception as e:
logger.warning(f"Pro Aurum gescheitert: {e} — wechsle zu Degussa")
error = str(e)
try:
data = degussa.scrape()
source = "degussa"
error = None
logger.info(f"Degussa Fallback: {len(data['gold'])} Gold, {len(data['silver'])} Silber")
except Exception as e2:
logger.error(f"Degussa auch gescheitert: {e2}")
log_run("both_failed", False, 0, 0,
(datetime.now() - t0).total_seconds(), f"PA: {e} | DG: {e2}")
sys.exit(1)
save_prices(data)
duration = (datetime.now() - t0).total_seconds()
log_run(source, True, len(data["gold"]), len(data["silver"]), duration, error)
logger.info(f"=== Fertig in {duration:.0f}s: {len(data['gold'])} Gold, {len(data['silver'])} Silber ===")
if __name__ == "__main__":
main()