Add full-UK CSV source (matthewgall/fuelfinder-archive, OGL, hourly); auto source selection CSV/API
This commit is contained in:
+85
-18
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user