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