This repository has been archived on 2025-11-19. You can view files and clone it, but cannot push or open issues or pull requests.
Skyfeed_archive/internal/geo/towns_updater.go

136 lines
3.6 KiB
Go

package geo
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"time"
"github.com/leaktechnologies/skyfeed/internal/config"
)
const (
// Official Geographical Names Board of Canada WFS API
// Docs: https://www.nrcan.gc.ca/earth-sciences/geography/geographical-names-board-canada/download-geographical-names-data/10786
gnbcAPIURL = "https://geogratis.gc.ca/geonames/servlet/com.gc.ccra.geonames.webservices.GeographicalNamesService?service=WFS&request=GetFeature&version=2.0.0&typeNames=geonames:geoname_eng&outputFormat=json&featureCode=PPL"
townsFile = "towns.json"
maxFetchTime = 5 * time.Minute
)
// TownRecord represents a single Canadian town.
type TownRecord struct {
Name string `json:"name"`
Province string `json:"province"`
Lat float64 `json:"lat"`
Lon float64 `json:"lon"`
}
// EnsureTownIndexUpToDate checks if the towns index needs updating (monthly).
func EnsureTownIndexUpToDate() error {
dest := filepath.Join(config.DataDir, townsFile)
info, err := os.Stat(dest)
if os.IsNotExist(err) {
fmt.Println("[geo] No town index found, downloading...")
return downloadTownIndex(dest)
}
if err != nil {
return fmt.Errorf("unable to check town index: %w", err)
}
modTime := info.ModTime().UTC()
now := time.Now().UTC()
firstOfMonth := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
if modTime.Before(firstOfMonth) {
fmt.Println("[geo] Town index is older than this month, refreshing...")
return downloadTownIndex(dest)
}
fmt.Println("[geo] Town index is current.")
return nil
}
// ForceUpdateTownIndex forces an immediate rebuild.
func ForceUpdateTownIndex() error {
dest := filepath.Join(config.DataDir, townsFile)
fmt.Println("[geo] Forcing town index update...")
return downloadTownIndex(dest)
}
// downloadTownIndex fetches and stores the Canadian town dataset.
func downloadTownIndex(dest string) error {
client := &http.Client{Timeout: maxFetchTime}
fmt.Println("[geo] Fetching town data from GNBC WFS API...")
resp, err := client.Get(gnbcAPIURL)
if err != nil {
return fmt.Errorf("failed to fetch town dataset: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("unexpected HTTP status: %s", resp.Status)
}
raw, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("failed to read GNBC response: %w", err)
}
towns, err := parseGNBCJSON(raw)
if err != nil {
return fmt.Errorf("failed to parse GNBC JSON: %w", err)
}
data, err := json.MarshalIndent(towns, "", " ")
if err != nil {
return fmt.Errorf("failed to encode towns: %w", err)
}
if err := os.WriteFile(dest, data, 0644); err != nil {
return fmt.Errorf("failed to write %s: %w", dest, err)
}
fmt.Printf("[geo] Town index updated → %s (%d towns)\n", dest, len(towns))
return nil
}
// parseGNBCJSON extracts relevant town info from the GNBC GeoJSON.
func parseGNBCJSON(data []byte) ([]TownRecord, error) {
var response struct {
Features []struct {
Properties struct {
Name string `json:"name"`
Province string `json:"province"`
Latitude float64 `json:"latitude"`
Longitude float64 `json:"longitude"`
} `json:"properties"`
} `json:"features"`
}
if err := json.Unmarshal(data, &response); err != nil {
return nil, fmt.Errorf("invalid GNBC JSON: %w", err)
}
var towns []TownRecord
for _, f := range response.Features {
p := f.Properties
if p.Name == "" || p.Province == "" {
continue
}
towns = append(towns, TownRecord{
Name: p.Name,
Province: p.Province,
Lat: p.Latitude,
Lon: p.Longitude,
})
}
return towns, nil
}