Fetch station data from regieessencequebec.ca/stations.geojson.gz instead of the Excel file whose URL changed with every update, causing persistent 404 errors. Add 5-minute in-memory cache. Drop excelize dependency — now stdlib-only.
249 lines
5.6 KiB
Go
249 lines
5.6 KiB
Go
package main
|
|
|
|
import (
|
|
"compress/gzip"
|
|
"embed"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
//go:embed static/*
|
|
var staticFiles embed.FS
|
|
|
|
const (
|
|
geojsonURL = "https://regieessencequebec.ca/stations.geojson.gz"
|
|
defaultPort = "8080"
|
|
cacheTTL = 5 * time.Minute
|
|
)
|
|
|
|
// GeoJSON structures matching the upstream format.
|
|
type GeoJSONResponse struct {
|
|
Type string `json:"type"`
|
|
Metadata *GeoJSONMeta `json:"metadata,omitempty"`
|
|
Features json.RawMessage `json:"features"`
|
|
}
|
|
|
|
type GeoJSONMeta struct {
|
|
GeneratedAt string `json:"generated_at"`
|
|
ExcelURL string `json:"excel_url"`
|
|
TotalStations int `json:"total_stations"`
|
|
ExcelSizeBytes int `json:"excel_size_bytes"`
|
|
}
|
|
|
|
// Station is our simplified JSON shape for the frontend.
|
|
type Station struct {
|
|
Name string `json:"name"`
|
|
Brand string `json:"brand"`
|
|
Address string `json:"address"`
|
|
Region string `json:"region"`
|
|
PostalCode string `json:"postalCode"`
|
|
Lat float64 `json:"lat"`
|
|
Lng float64 `json:"lng"`
|
|
Regular float64 `json:"regular"`
|
|
Super float64 `json:"super"`
|
|
Diesel float64 `json:"diesel"`
|
|
}
|
|
|
|
type StationsResponse struct {
|
|
LastUpdated string `json:"lastUpdated"`
|
|
Stations []Station `json:"stations"`
|
|
}
|
|
|
|
// In-memory cache
|
|
var (
|
|
cacheMu sync.RWMutex
|
|
cachedResp *StationsResponse
|
|
cacheExpiry time.Time
|
|
)
|
|
|
|
func main() {
|
|
port := os.Getenv("PORT")
|
|
if port == "" {
|
|
port = defaultPort
|
|
}
|
|
|
|
staticSub, err := fs.Sub(staticFiles, "static")
|
|
if err != nil {
|
|
log.Fatalf("static files: %v", err)
|
|
}
|
|
|
|
http.HandleFunc("/api/stations", handleStations)
|
|
http.Handle("/", http.FileServer(http.FS(staticSub)))
|
|
|
|
log.Printf("Listening on http://localhost:%s", port)
|
|
log.Fatal(http.ListenAndServe(":"+port, nil))
|
|
}
|
|
|
|
func handleStations(w http.ResponseWriter, r *http.Request) {
|
|
resp, err := getStations()
|
|
if err != nil {
|
|
http.Error(w, fmt.Sprintf("error: %v", err), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Header().Set("Cache-Control", "public, max-age=300")
|
|
json.NewEncoder(w).Encode(resp)
|
|
}
|
|
|
|
func getStations() (*StationsResponse, error) {
|
|
cacheMu.RLock()
|
|
if cachedResp != nil && time.Now().Before(cacheExpiry) {
|
|
defer cacheMu.RUnlock()
|
|
return cachedResp, nil
|
|
}
|
|
cacheMu.RUnlock()
|
|
|
|
resp, err := fetchAndParse()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
cacheMu.Lock()
|
|
cachedResp = resp
|
|
cacheExpiry = time.Now().Add(cacheTTL)
|
|
cacheMu.Unlock()
|
|
|
|
return resp, nil
|
|
}
|
|
|
|
func fetchAndParse() (*StationsResponse, error) {
|
|
log.Println("Fetching GeoJSON data from upstream...")
|
|
|
|
req, err := http.NewRequest("GET", geojsonURL, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("creating request: %w", err)
|
|
}
|
|
req.Header.Set("Accept-Encoding", "gzip")
|
|
req.Header.Set("User-Agent", "essence-quebec-map/1.0")
|
|
|
|
client := &http.Client{Timeout: 30 * time.Second}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("fetching data: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("unexpected status: %d", resp.StatusCode)
|
|
}
|
|
|
|
// Handle gzip if the response is compressed
|
|
var reader io.Reader = resp.Body
|
|
if resp.Header.Get("Content-Encoding") == "gzip" || strings.HasSuffix(geojsonURL, ".gz") {
|
|
gz, err := gzip.NewReader(resp.Body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("gzip reader: %w", err)
|
|
}
|
|
defer gz.Close()
|
|
reader = gz
|
|
}
|
|
|
|
var geojson GeoJSONResponse
|
|
if err := json.NewDecoder(reader).Decode(&geojson); err != nil {
|
|
return nil, fmt.Errorf("decoding geojson: %w", err)
|
|
}
|
|
|
|
// Parse features
|
|
var features []struct {
|
|
Geometry struct {
|
|
Coordinates [2]float64 `json:"coordinates"`
|
|
} `json:"geometry"`
|
|
Properties struct {
|
|
Name string `json:"Name"`
|
|
Brand string `json:"brand"`
|
|
Address string `json:"Address"`
|
|
PostalCode string `json:"PostalCode"`
|
|
Region string `json:"Region"`
|
|
Prices []struct {
|
|
GasType string `json:"GasType"`
|
|
Price *string `json:"Price"`
|
|
IsAvailable bool `json:"IsAvailable"`
|
|
} `json:"Prices"`
|
|
} `json:"properties"`
|
|
}
|
|
|
|
if err := json.Unmarshal(geojson.Features, &features); err != nil {
|
|
return nil, fmt.Errorf("parsing features: %w", err)
|
|
}
|
|
|
|
var stations []Station
|
|
for _, f := range features {
|
|
lng := f.Geometry.Coordinates[0]
|
|
lat := f.Geometry.Coordinates[1]
|
|
if lat == 0 && lng == 0 {
|
|
continue
|
|
}
|
|
|
|
s := Station{
|
|
Name: f.Properties.Name,
|
|
Brand: f.Properties.Brand,
|
|
Address: f.Properties.Address,
|
|
Region: f.Properties.Region,
|
|
PostalCode: f.Properties.PostalCode,
|
|
Lat: lat,
|
|
Lng: lng,
|
|
}
|
|
|
|
for _, p := range f.Properties.Prices {
|
|
if p.Price == nil || !p.IsAvailable {
|
|
continue
|
|
}
|
|
price := parsePrice(*p.Price)
|
|
if price <= 0 {
|
|
continue
|
|
}
|
|
switch p.GasType {
|
|
case "Régulier":
|
|
s.Regular = price
|
|
case "Super":
|
|
s.Super = price
|
|
case "Diesel":
|
|
s.Diesel = price
|
|
}
|
|
}
|
|
|
|
if s.Regular <= 0 {
|
|
continue
|
|
}
|
|
|
|
stations = append(stations, s)
|
|
}
|
|
|
|
lastUpdated := ""
|
|
if geojson.Metadata != nil && geojson.Metadata.GeneratedAt != "" {
|
|
lastUpdated = geojson.Metadata.GeneratedAt
|
|
}
|
|
|
|
log.Printf("Parsed %d stations (last updated: %s)", len(stations), lastUpdated)
|
|
|
|
return &StationsResponse{
|
|
LastUpdated: lastUpdated,
|
|
Stations: stations,
|
|
}, nil
|
|
}
|
|
|
|
// parsePrice converts "190.9¢" to 190.9
|
|
func parsePrice(s string) float64 {
|
|
s = strings.TrimSpace(s)
|
|
if s == "" || s == "N/D" {
|
|
return 0
|
|
}
|
|
s = strings.TrimSuffix(s, "¢")
|
|
s = strings.TrimSuffix(s, "\u00a2") // cent sign
|
|
|
|
var v float64
|
|
_, err := fmt.Sscanf(s, "%f", &v)
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
return v
|
|
}
|