Add full-UK CSV source (matthewgall/fuelfinder-archive, OGL, hourly); auto source selection CSV/API

This commit is contained in:
FuelBoard Contributor
2026-08-11 14:34:04 +01:00
parent 6a8f3db2d4
commit 9d08ab80fd
+84 -17
View File
@@ -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) 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 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 # In-memory cache state
@@ -49,9 +61,63 @@ _state = {
"last_error": None, "last_error": None,
"sync_attempts": 0, "sync_attempts": 0,
"sync_failures": 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: def _get_token(client: httpx.Client) -> str:
"""Return a valid bearer token, refreshing via client-credentials grant.""" """Return a valid bearer token, refreshing via client-credentials grant."""
if _state["token"] and time.time() < _state["token_expires_at"] - TOKEN_EXPIRY_BUFFER: 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).""" """Refresh the station cache if stale (or on first run)."""
_state["sync_attempts"] += 1 _state["sync_attempts"] += 1
try: try:
if DEMO_MODE: if SOURCE == "csv":
raise RuntimeError("Fuel Finder credentials not configured (FUEL_API_CLIENT_ID/SECRET)") _state["stations"] = _fetch_csv()
data = _fetch_prices() _state["source_used"] = "csv"
_state["stations"] = data 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["stations_fetched_at"] = time.time()
_state["last_error"] = None _state["last_error"] = None
except Exception as exc: # keep serving stale cache on failure except Exception as exc: # keep serving stale cache on failure
@@ -101,7 +176,8 @@ def _refresh_cache() -> None:
@asynccontextmanager @asynccontextmanager
async def lifespan(_: FastAPI): async def lifespan(_: FastAPI):
if not DEMO_MODE: # 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() _refresh_cache()
yield yield
@@ -183,22 +259,13 @@ def stations(
radius: float = Query(20.0, description="Search radius in km"), radius: float = Query(20.0, description="Search radius in km"),
limit: int = Query(10, ge=1, le=100), limit: int = Query(10, ge=1, le=100),
): ):
if DEMO_MODE: if _state["stations"] is None:
return JSONResponse( raise HTTPException(status_code=502, detail="No data cached yet and upstream fetch failed")
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)",
},
)
# Trigger a refresh if stale; keep serving stale cache if it fails. # Trigger a refresh if stale; keep serving stale cache if it fails.
if time.time() - _state["stations_fetched_at"] > CACHE_TTL: if time.time() - _state["stations_fetched_at"] > CACHE_TTL:
_refresh_cache() _refresh_cache()
if _state["stations"] is None:
raise HTTPException(status_code=502, detail="No data cached yet and upstream fetch failed")
rows = [] rows = []
for station in _stations_list(): for station in _stations_list():
price = _fuel_price(station, fuel) price = _fuel_price(station, fuel)