Spaces:
Sleeping
Sleeping
| """Fetch and normalise French fuel-price data from the open-data portal. | |
| Data source: https://public.opendatasoft.com/explore/dataset/prix_des_carburants_j_7 | |
| The dataset holds the prices reported by every petrol station in France over the | |
| last 7 days, refreshed continuously upstream. | |
| """ | |
| from __future__ import annotations | |
| import json | |
| import os | |
| from datetime import datetime, timezone | |
| import requests | |
| DATASET = "prix_des_carburants_j_7" | |
| EXPORT_URL = ( | |
| "https://public.opendatasoft.com/api/explore/v2.1/" | |
| f"catalog/datasets/{DATASET}/exports/json" | |
| ) | |
| # Human label -> price field in the dataset (ordered: most common fuel first). | |
| FUELS: dict[str, str] = { | |
| "Gazole": "price_gazole", | |
| "SP95": "price_sp95", | |
| "SP98": "price_sp98", | |
| "E10": "price_e10", | |
| "E85": "price_e85", | |
| "GPLc": "price_gplc", | |
| } | |
| # Plausible price band (€/L). Real road fuels in France sit well inside this; | |
| # anything outside is a data-entry error and is dropped. | |
| PRICE_MIN, PRICE_MAX = 0.4, 4.0 | |
| def fetch_raw(timeout: int = 180) -> list[dict]: | |
| """Download every record from the dataset's bulk export endpoint.""" | |
| resp = requests.get(EXPORT_URL, params={"timezone": "Europe/Paris"}, timeout=timeout) | |
| resp.raise_for_status() | |
| return resp.json() | |
| def normalise(raw: list[dict]) -> list[dict]: | |
| """Keep geolocated stations that report at least one price; tidy the fields.""" | |
| stations = [] | |
| for r in raw: | |
| geo = r.get("geo_point") or {} | |
| lat, lon = geo.get("lat"), geo.get("lon") | |
| if lat is None or lon is None: | |
| continue | |
| # Some stations report nonsense prices (e.g. 0.002 €/L); keep only | |
| # plausible values so they don't skew the cheapest/cost figures. | |
| prices = { | |
| label: (v if isinstance(v, (int, float)) and PRICE_MIN <= v <= PRICE_MAX else None) | |
| for label, field in FUELS.items() | |
| for v in (r.get(field),) | |
| } | |
| if not any(v is not None for v in prices.values()): | |
| continue | |
| stations.append( | |
| { | |
| "id": r.get("id"), | |
| "name": (r.get("name") or "").strip() or "Station", | |
| "brand": (r.get("brand") or "").strip(), | |
| "address": (r.get("address") or "").strip(), | |
| "city": (r.get("city") or "").strip(), | |
| "cp": (r.get("cp") or "").strip(), | |
| "lat": lat, | |
| "lon": lon, | |
| "prices": prices, | |
| "update": r.get("update"), | |
| "highway": r.get("pop") == "A", # A = autoroute, R = route | |
| "open24": is_24h(r.get("automate_24_24")), | |
| "services": clean_services(r.get("services")), | |
| } | |
| ) | |
| return stations | |
| def is_24h(value) -> bool: | |
| """Whether the station has 24/7 automated payment (field is bool or Oui/Non).""" | |
| if value is True: | |
| return True | |
| return isinstance(value, str) and value.strip().lower() in ("oui", "yes", "true", "1") | |
| def clean_services(value) -> list[str]: | |
| """Normalise the `services` field (list, or {'service': [...]}) to a string list.""" | |
| if isinstance(value, dict): | |
| value = value.get("service") | |
| if not isinstance(value, list): | |
| return [] | |
| return [str(s).strip() for s in value if str(s).strip()] | |
| def fetch_stations(cache_path: str | None = None) -> list[dict]: | |
| """Fetch + normalise the live dataset, optionally caching the result to disk.""" | |
| stations = normalise(fetch_raw()) | |
| if cache_path: | |
| os.makedirs(os.path.dirname(cache_path) or ".", exist_ok=True) | |
| payload = { | |
| "fetched_at": datetime.now(timezone.utc).isoformat(), | |
| "count": len(stations), | |
| "stations": stations, | |
| } | |
| with open(cache_path, "w", encoding="utf-8") as fh: | |
| json.dump(payload, fh, ensure_ascii=False) | |
| return stations | |
| if __name__ == "__main__": | |
| data = fetch_stations() | |
| print(f"Fetched {len(data)} geolocated stations.") | |
| sample = data[0] | |
| print("Example:", json.dumps(sample, ensure_ascii=False, indent=2)) | |