diff --git a/app/main.py b/app/main.py index 63185ff..b17bfa7 100644 --- a/app/main.py +++ b/app/main.py @@ -35,7 +35,19 @@ PRICES_URL = os.environ.get("FUEL_API_PRICES_URL", "https://api.fuelfinder.servi CACHE_TTL = int(os.environ.get("FUEL_CACHE_TTL", "300")) # seconds (5 min) TOKEN_EXPIRY_BUFFER = int(os.environ.get("FUEL_TOKEN_BUFFER", "60")) # seconds -DEMO_MODE = not (CLIENT_ID and CLIENT_SECRET) +# Public full-UK mirror of the Fuel Finder dataset (GitHub Actions, hourly), +# used as the default source when OAuth credentials are not configured. +# Licence: Open Government Licence v3.0. No auth required. +CSV_URL = os.environ.get( + "FUEL_CSV_URL", + "https://raw.githubusercontent.com/matthewgall/fuelfinder-archive/main/data.csv", +) +CSV_REFRESH_TTL = int(os.environ.get("FUEL_CSV_TTL", "3600")) # seconds (1h) + +# Source selection: "auto" (CSV if no creds, API if creds), "csv", "api" +SOURCE = os.environ.get("FUEL_SOURCE", "auto").lower() + +DEMO_MODE = not (CLIENT_ID and CLIENT_SECRET) and SOURCE != "csv" # --------------------------------------------------------------------------- # In-memory cache state @@ -49,9 +61,63 @@ _state = { "last_error": None, "sync_attempts": 0, "sync_failures": 0, + "source_used": None, } +def _parse_csv_to_stations(text: str) -> list: + """Parse the Fuel Finder archive CSV into a station list. + + Header uses dotted keys: forecourts.trading_name, forecourts.location.latitude, + forecourts.fuel_price.E10, forecourts.price_change_effective_timestamp.E10, etc. + """ + import csv + import io + + reader = csv.DictReader(io.StringIO(text)) + stations = [] + for row in reader: + def num(key): + try: + val = row.get(key) + return float(val) if val not in (None, "") else None + except (TypeError, ValueError): + return None + + lat = num("forecourts.location.latitude") + lng = num("forecourts.location.longitude") + prices = {} + for grade, label in (("E5", "E5"), ("E10", "E10"), ("B7S", "DIESEL"), ("B7P", "DIESEL")): + p = num(f"forecourts.fuel_price.{grade}") + if p is not None and grade not in prices: + prices[label] = p + stations.append({ + "id": row.get("forecourts.node_id"), + "name": row.get("forecourts.trading_name"), + "brand": row.get("forecourts.brand_name"), + "address": (row.get("forecourts.location.address_line_1") or "") + " " + (row.get("forecourts.location.address_line_2") or ""), + "postcode": row.get("forecourts.location.postcode"), + "lat": lat, + "lng": lng, + "prices": prices, + "price_updated": row.get("forecourts.price_change_effective_timestamp.E10") + or row.get("forecourts.price_change_effective_timestamp.E5"), + "is_motorway": row.get("forecourts.is_motorway_service_station") == "true", + }) + return stations + + +def _fetch_csv() -> list: + """Download the hourly full-UK CSV mirror.""" + with httpx.Client(timeout=120) as client: + resp = client.get(CSV_URL) + resp.raise_for_status() + stations = _parse_csv_to_stations(resp.text) + if not stations: + raise RuntimeError("CSV parse returned zero stations") + return stations + + def _get_token(client: httpx.Client) -> str: """Return a valid bearer token, refreshing via client-credentials grant.""" if _state["token"] and time.time() < _state["token_expires_at"] - TOKEN_EXPIRY_BUFFER: @@ -88,10 +154,19 @@ def _refresh_cache() -> None: """Refresh the station cache if stale (or on first run).""" _state["sync_attempts"] += 1 try: - if DEMO_MODE: - raise RuntimeError("Fuel Finder credentials not configured (FUEL_API_CLIENT_ID/SECRET)") - data = _fetch_prices() - _state["stations"] = data + if SOURCE == "csv": + _state["stations"] = _fetch_csv() + _state["source_used"] = "csv" + elif SOURCE == "api": + _state["stations"] = _fetch_prices() + _state["source_used"] = "api" + else: # auto: API if creds present, otherwise public CSV + if not DEMO_MODE: + _state["stations"] = _fetch_prices() + _state["source_used"] = "api" + else: + _state["stations"] = _fetch_csv() + _state["source_used"] = "csv" _state["stations_fetched_at"] = time.time() _state["last_error"] = None except Exception as exc: # keep serving stale cache on failure @@ -101,8 +176,9 @@ def _refresh_cache() -> None: @asynccontextmanager async def lifespan(_: FastAPI): - if not DEMO_MODE: - _refresh_cache() + # Always try to warm the cache. In auto mode with no credentials this + # fetches the public full-UK CSV; with credentials it uses the OAuth API. + _refresh_cache() yield @@ -183,22 +259,13 @@ def stations( radius: float = Query(20.0, description="Search radius in km"), limit: int = Query(10, ge=1, le=100), ): - if DEMO_MODE: - return JSONResponse( - status_code=503, - content={ - "error": "Fuel Finder credentials not configured on the relay", - "hint": "Set FUEL_API_CLIENT_ID and FUEL_API_CLIENT_SECRET (see README)", - }, - ) + if _state["stations"] is None: + raise HTTPException(status_code=502, detail="No data cached yet and upstream fetch failed") # Trigger a refresh if stale; keep serving stale cache if it fails. if time.time() - _state["stations_fetched_at"] > CACHE_TTL: _refresh_cache() - if _state["stations"] is None: - raise HTTPException(status_code=502, detail="No data cached yet and upstream fetch failed") - rows = [] for station in _stations_list(): price = _fuel_price(station, fuel)