"""Kühlfinder Live-Crawler — eigener Verfügbarkeits-/Preis-Scraper. Muster gespiegelt vom AIDA Preisradar (FastAPI + httpx + APScheduler + /app/data + Coolify). Scrapt echte, server-gerenderte Händlerseiten (OBI, Bauhaus), persistiert Snapshots + Restock-Historie, liefert /api/live (CORS). Keine Drittdaten (kein braucheklima-Import).""" import os, re, json, html, logging, asyncio from datetime import datetime, timezone import httpx from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.gzip import GZipMiddleware from fastapi.responses import JSONResponse from apscheduler.schedulers.asyncio import AsyncIOScheduler from apscheduler.triggers.interval import IntervalTrigger from sources import SOURCES logging.basicConfig(level=logging.INFO); log = logging.getLogger("kf-crawler") DATA = os.getenv("DATA_DIR","/app/data"); os.makedirs(DATA, exist_ok=True) LIVE = os.path.join(DATA,"live.json"); HIST = os.path.join(DATA,"history.json") INTERVAL_MIN = int(os.getenv("CRAWL_INTERVAL_MIN","30")) UA = { "User-Agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", "Accept":"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", "Accept-Language":"de-DE,de;q=0.9,en;q=0.6", "sec-ch-ua":'"Not_A Brand";v="8", "Chromium";v="120", "Google Chrome";v="120"', "sec-ch-ua-mobile":"?0","sec-ch-ua-platform":'"Windows"', "sec-fetch-dest":"document","sec-fetch-mode":"navigate","sec-fetch-site":"none","sec-fetch-user":"?1", "upgrade-insecure-requests":"1", } # Optionaler Proxy (DE-Residential empfohlen) – wie AidaBot. Env: HTTP_PROXY_URL PROXY = os.getenv("HTTP_PROXY_URL","").strip() or None def _num(s): s=s.replace(".","").replace("\xa0"," ") m=re.search(r"(\d{2,4})[,](\d{2})",s) or re.search(r"(\d{2,4}),-",s) or re.search(r"(\d{2,4})\s*€",s) if not m: return None g=m.groups() return float(g[0]) + (float(g[1])/100 if len(g)>1 and g[1] else 0) def clean_price(v): v=(v or "").strip() if not v: return None if "," in v: v=v.replace(".","").replace(",",".") m=re.search(r"\d+(?:\.\d+)?", v) return float(m.group()) if m else None def price_from_meta(h): for pat in ("product:price:amount","og:price:amount"): m=re.search(r']+(?:property|name)=["\']'+pat+r'["\'][^>]+content=["\']([^"\']+)', h, re.I) or \ re.search(r']+content=["\']([^"\']+)["\'][^>]+(?:property|name)=["\']'+pat+r'["\']', h, re.I) if m: p=clean_price(m.group(1)) if p: return p return None def parse(htmltext): # 1) JSON-LD Offer price=None; avail=None for m in re.finditer(r']+application/ld\+json[^>]*>(.*?)', htmltext, re.S|re.I): try: data=json.loads(m.group(1)) except Exception: continue for node in (data if isinstance(data,list) else [data]): graph = node.get("@graph",[node]) if isinstance(node,dict) else [node] for g in graph: if not isinstance(g,dict): continue off=g.get("offers") if off: off=off[0] if isinstance(off,list) else off try: price=float(off.get("price") or off.get("lowPrice")) except Exception: pass avail=str(off.get("availability") or "") # 2) Heuristik low=htmltext.lower() in_store = ("vorrätig" in low or "verfügbar" in low) and not ("nicht vorrätig" in low or "nicht verfügbar" in low) online = ("in den warenkorb" in low) or ("lieferung" in low and "nicht möglich" not in low) or (avail and "instock" in avail.lower()) if price is None: price=price_from_meta(htmltext) if price is None: mp=_num(htmltext[:200000]) price=mp if in_store: availability="in_stock" elif online: availability="online_available" elif price is not None: availability="out_of_stock" else: availability="unknown" # erreichbar, aber Markup (noch) nicht geparst return price, availability async def fetch_one(client, src): try: r= await client.get(src["url"], timeout=25, follow_redirects=True) if r.status_code in (404,410): # Produktseite verschwunden -> als entfernt kennzeichnen (das kann braucheklima nicht) return {"chain":src["chain"],"kind":src["kind"],"productId":src["productId"],"name":src["name"], "url":src["url"],"price":None,"availability":"discontinued","status":r.status_code} if r.status_code in (401,403,429): return {"chain":src["chain"],"kind":src["kind"],"productId":src["productId"],"name":src["name"], "url":src["url"],"price":None,"availability":"blocked","status":r.status_code} if r.status_code!=200: return {**src,"price":None,"availability":"unknown","status":r.status_code} price, avail = parse(r.text) return {"chain":src["chain"],"kind":src["kind"],"cc":src.get("cc","DE"),"productId":src["productId"],"name":src["name"], "url":src["url"],"price":price,"availability":avail} except Exception as e: log.warning("fetch fail %s: %s", src["url"], e) return {**src,"price":None,"availability":"unknown"} def load(path,default): try: with open(path) as f: return json.load(f) except Exception: return default async def crawl(): now=datetime.now(timezone.utc).isoformat() async with httpx.AsyncClient(proxies=PROXY, headers=UA, http2=False) as client: results=[] for src in SOURCES: results.append(await fetch_one(client, src)) await asyncio.sleep(1.2) # höflich: Jitter/Rate-Limit # Restock-Events prev={ (o["chain"],o["productId"]): o for o in load(LIVE,{}).get("offers",[]) } hist=load(HIST,{"events":[]}) for o in results: p=prev.get((o["chain"],o["productId"])) if p and p.get("availability")=="out_of_stock" and o.get("availability") in ("in_stock","online_available"): hist["events"].append({"chain":o["chain"],"productId":o["productId"],"name":o["name"],"at":now}) if p and p.get("availability")!="discontinued" and o.get("availability")=="discontinued": hist["events"].append({"chain":o["chain"],"productId":o["productId"],"name":o["name"],"at":now,"type":"discontinued"}) hist["events"]=hist["events"][-200:] # Preis-Aggregat je Produkt byp={} for o in results: if o.get("price"): byp.setdefault(o["productId"],[]).append(o["price"]) agg={k:{"min":min(v),"max":max(v),"n":len(v)} for k,v in byp.items()} gone=[o for o in results if o.get("availability")=="discontinued"] blocked=[o for o in results if o.get("availability")=="blocked"] snap={"generatedAt":now,"source":"kuehlfinder-livecrawler","intervalMin":INTERVAL_MIN,"discontinued":len(gone),"blocked":len(blocked),"proxy":bool(PROXY), "offers":results,"priceByProduct":agg,"feed":hist["events"][-20:][::-1]} json.dump(snap, open(LIVE,"w"), ensure_ascii=False) json.dump(hist, open(HIST,"w"), ensure_ascii=False) log.info("crawl done: %d offers, %d events", len(results), len(hist["events"])) return snap app=FastAPI(title="Kühlfinder Live-Crawler", version="0.1.0") app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["GET"], allow_headers=["*"]) app.add_middleware(GZipMiddleware, minimum_size=400) sched=AsyncIOScheduler() @app.on_event("startup") async def _start(): await crawl() sched.add_job(crawl, IntervalTrigger(minutes=INTERVAL_MIN), id="crawl", max_instances=1, coalesce=True) sched.start() @app.get("/health") def health(): return {"ok":True} @app.get("/api/live") def live(): return JSONResponse(load(LIVE,{"offers":[],"note":"warming up"}))