Replace GNBC WFS with GeoGratis towns endpoint and remove MaxMind

This commit is contained in:
Stu Leak 2025-11-13 21:35:18 -05:00
parent 7f00667213
commit a3e92212d1
7 changed files with 357 additions and 462 deletions

View File

@ -1,41 +1,41 @@
package main package main
import ( import (
"fmt" "fmt"
"os" "os"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"git.leaktechnologies.dev/Leak_Technologies/Skyfeed/internal/config" "git.leaktechnologies.dev/Leak_Technologies/Skyfeed/internal/config"
"git.leaktechnologies.dev/Leak_Technologies/Skyfeed/internal/geo" "git.leaktechnologies.dev/Leak_Technologies/Skyfeed/internal/geo"
"git.leaktechnologies.dev/Leak_Technologies/Skyfeed/internal/output" "git.leaktechnologies.dev/Leak_Technologies/Skyfeed/internal/output"
"git.leaktechnologies.dev/Leak_Technologies/Skyfeed/internal/weather" "git.leaktechnologies.dev/Leak_Technologies/Skyfeed/internal/weather"
) )
var rootCmd = &cobra.Command{ var rootCmd = &cobra.Command{
Use: "skyfeed", Use: "skyfeed",
Short: "Skyfeed - Open Weather Engine for Telefact and Terminal", Short: "Skyfeed - Open Weather Engine for Telefact and Terminal",
Long: `Skyfeed fetches and normalizes weather data from Environment Canada, Long: `Skyfeed fetches and normalizes weather data from Environment Canada,
using a local IP database for accurate geolocation. It supports both CLI and API modes.`, using automatic IP-based geolocation. It supports both CLI and API modes.`,
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
cmd.Help() cmd.Help()
}, },
} }
func main() { func main() {
// Initialize configuration and ensure data directories exist // Initialize configuration and ensure data directories exist
config.Init() config.Init()
// Register subcommands // Register subcommands
rootCmd.AddCommand(fetchCmd) rootCmd.AddCommand(fetchCmd)
rootCmd.AddCommand(showCmd) rootCmd.AddCommand(showCmd)
rootCmd.AddCommand(updateCmd) rootCmd.AddCommand(updateTownCmd)
rootCmd.AddCommand(debugLogoCmd) rootCmd.AddCommand(debugLogoCmd)
if err := rootCmd.Execute(); err != nil { if err := rootCmd.Execute(); err != nil {
fmt.Println("Error:", err) fmt.Println("Error:", err)
os.Exit(1) os.Exit(1)
} }
} }
// ---------------------------------------------------- // ----------------------------------------------------
@ -43,101 +43,106 @@ func main() {
// ---------------------------------------------------- // ----------------------------------------------------
var fetchCmd = &cobra.Command{ var fetchCmd = &cobra.Command{
Use: "fetch", Use: "fetch",
Short: "Fetch the latest weather data for your current location", Short: "Fetch the latest weather data for your current location",
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
output.LogInfo("Skyfeed: Checking IP database...")
if err := geo.EnsureIPDBUpToDate(); err != nil {
output.LogError(fmt.Sprintf("Failed to update IP DB: %v", err))
return
}
output.LogInfo("Skyfeed: Detecting location...") // Ensure town index is present and current
city, lat, lon, err := geo.GetUserLocation() output.LogInfo("Skyfeed: Checking town index…")
if err != nil { if err := geo.EnsureTownIndexUpToDate(); err != nil {
output.LogError(fmt.Sprintf("Could not determine location: %v", err)) output.LogError(fmt.Sprintf("Failed to update town index: %v", err))
return return
} }
output.LogInfo(fmt.Sprintf("Detected: %s (%.4f, %.4f)", city, lat, lon))
output.LogInfo("Finding nearest Environment Canada station...") // Detect location via new IP method
station, err := geo.FindNearestStation(lat, lon) output.LogInfo("Skyfeed: Detecting location…")
if err != nil { city, lat, lon, err := geo.GetUserLocation()
output.LogError(fmt.Sprintf("Station lookup failed: %v", err)) if err != nil {
return output.LogError(fmt.Sprintf("Could not determine location: %v", err))
} return
output.LogInfo(fmt.Sprintf("Nearest station: %s [%s]", station.Name, station.Code)) }
output.LogInfo(fmt.Sprintf("Detected: %s (%.4f, %.4f)", city, lat, lon))
output.LogInfo("Fetching latest weather data...") // Find nearest EC station
province := station.Province output.LogInfo("Finding nearest Environment Canada station…")
station, err := geo.FindNearestStation(lat, lon)
if err != nil {
output.LogError(fmt.Sprintf("Station lookup failed: %v", err))
return
}
output.LogInfo(fmt.Sprintf("Nearest station: %s [%s]", station.Name, station.Code))
data, err := weather.FetchCurrent(station.Code, province) // Fetch weather
if err != nil { output.LogInfo("Fetching latest weather data…")
output.LogError(fmt.Sprintf("Weather fetch failed: %v", err)) province := station.Province
return
}
// Render dynamic header data, err := weather.FetchCurrent(station.Code, province)
output.RenderLogo(data.Condition) if err != nil {
fmt.Println() output.LogError(fmt.Sprintf("Weather fetch failed: %v", err))
return
}
// Main output // Render dynamic header/logo
fmt.Println(output.FormatWeatherCLI(data, true)) output.PrintLogo(data.Condition)
}, fmt.Println()
// Main output
fmt.Println(output.FormatWeatherCLI(data, true))
},
} }
var showCmd = &cobra.Command{ var showCmd = &cobra.Command{
Use: "show", Use: "show",
Short: "Show cached weather data from disk", Short: "Show cached weather data from disk",
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
data, err := weather.LoadCached() data, err := weather.LoadCached()
if err != nil { if err != nil {
output.LogError(fmt.Sprintf("Failed to load cache: %v", err)) output.LogError(fmt.Sprintf("Failed to load cache: %v", err))
return return
} }
output.RenderLogo(data.Condition) output.PrintLogo(data.Condition)
fmt.Println() fmt.Println()
fmt.Println(output.FormatWeatherCLI(data, true)) fmt.Println(output.FormatWeatherCLI(data, true))
}, },
} }
var updateCmd = &cobra.Command{ var updateTownCmd = &cobra.Command{
Use: "update-ipdb", Use: "update-towns",
Short: "Manually update the local IP geolocation database", Short: "Manually update the Canadian towns geolocation index",
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
output.LogInfo("Forcing IP database update...") output.LogInfo("Forcing town index update…")
if err := geo.ForceUpdateIPDB(); err != nil { if err := geo.ForceUpdateTownIndex(); err != nil {
output.LogError(fmt.Sprintf("Update failed: %v", err)) output.LogError(fmt.Sprintf("Update failed: %v", err))
return return
} }
output.LogSuccess("IP database updated successfully.") output.LogSuccess("Town index updated successfully.")
}, },
} }
// ---------------------------------------------------- // ----------------------------------------------------
// Debug-only utility command // Debug-only ASCII logo renderer
// ---------------------------------------------------- // ----------------------------------------------------
var debugLogoCmd = &cobra.Command{ var debugLogoCmd = &cobra.Command{
Use: "debug-logo [condition]", Use: "debug-logo [condition]",
Hidden: true, Hidden: true,
Short: "Render the dynamic ASCII Skyfeed logo for a given condition (debug use only)", Short: "Render the dynamic ASCII Skyfeed logo for a given condition",
Long: `This command renders the Skyfeed ASCII logo using a simulated Long: `This command renders the Skyfeed ASCII logo using a simulated
weather condition. It is intended strictly for development and testing. weather condition. It is intended strictly for development and testing.
Example: Example:
skyfeed debug-logo "Light Snow"`, skyfeed debug-logo "Light Snow"`,
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
if len(args) == 0 { if len(args) == 0 {
fmt.Println("Usage: skyfeed debug-logo \"Weather Condition\"") fmt.Println("Usage: skyfeed debug-logo \"Weather Condition\"")
fmt.Println("Example: skyfeed debug-logo \"Sunny\"") fmt.Println("Example: skyfeed debug-logo \"Sunny\"")
return return
} }
condition := args[0] condition := args[0]
fmt.Printf("Rendering logo for condition: %s\n\n", condition) fmt.Printf("Rendering logo for condition: %s\n\n", condition)
output.RenderLogo(condition) output.PrintLogo(condition)
}, },
} }

View File

@ -1,113 +1,148 @@
package geo package geo
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"net" "net"
"net/http" "net/http"
"path/filepath" "strings"
"time" "time"
"git.leaktechnologies.dev/Leak_Technologies/Skyfeed/internal/config"
"github.com/oschwald/geoip2-golang"
) )
// GetUserLocation resolves the user's public IP into a city and coordinates. // -------------------------------
// It will try multiple fallback IP providers if the first one fails. // Public IP + Geolocation (ipwho.is)
// -------------------------------
// GetUserLocation resolves the user's IP into:
// city, latitude, longitude
func GetUserLocation() (string, float64, float64, error) { func GetUserLocation() (string, float64, float64, error) {
fmt.Println("[geo] Detecting location...") fmt.Println("[geo] Detecting location...")
ip, err := fetchPublicIP() ip, err := fetchPublicIP()
if err != nil { if err != nil {
return "", 0, 0, fmt.Errorf("failed to resolve public IP: %w", err) return "", 0, 0, fmt.Errorf("failed to resolve public IP: %w", err)
} }
dbPath := filepath.Join(config.DataDir, "GeoLite2-City.mmdb") city, province, lat, lon, err := lookupIP(ip.String())
db, err := geoip2.Open(dbPath) if err != nil {
if err != nil { return "", 0, 0, fmt.Errorf("IP geolocation failed: %w", err)
return "", 0, 0, fmt.Errorf("failed to open GeoLite2 database: %w", err) }
}
defer db.Close()
record, err := db.City(ip) // e.g., "Ottawa, Ontario"
if err != nil { locationName := fmt.Sprintf("%s, %s", city, province)
return "", 0, 0, fmt.Errorf("geoip lookup failed: %w", err)
}
city := record.City.Names["en"] fmt.Printf("[geo] Detected: %s (%.4f, %.4f)\n", locationName, lat, lon)
prov := "" return locationName, lat, lon, nil
if len(record.Subdivisions) > 0 {
prov = record.Subdivisions[0].Names["en"]
}
lat := record.Location.Latitude
lon := record.Location.Longitude
if city == "" && prov == "" {
return "", 0, 0, fmt.Errorf("no location info found for IP %s", ip.String())
}
fmt.Printf("[geo] Detected: %s, %s (%.4f, %.4f)\n", city, prov, lat, lon)
return fmt.Sprintf("%s, %s", city, prov), lat, lon, nil
} }
// fetchPublicIP tries multiple reliable endpoints for the public IPv4 address. // -------------------------------
// PUBLIC IP DETECTION
// -------------------------------
func fetchPublicIP() (net.IP, error) { func fetchPublicIP() (net.IP, error) {
providers := []string{ providers := []string{
"https://ipv4.icanhazip.com", "https://ipv4.icanhazip.com",
"https://api.ipify.org?format=json", "https://api.ipify.org?format=json",
"https://ifconfig.co/json", "https://ifconfig.co/json",
} }
client := &http.Client{Timeout: 5 * time.Second} client := &http.Client{Timeout: 5 * time.Second}
for _, url := range providers { for _, url := range providers {
ip, err := tryProvider(url, client) ip, err := tryProvider(url, client)
if err == nil && ip != nil { if err == nil && ip != nil {
return ip, nil return ip, nil
} }
fmt.Println("[geo] Fallback:", err) fmt.Println("[geo] Fallback:", err)
} }
return nil, fmt.Errorf("all IP detection methods failed") return nil, fmt.Errorf("all IP detection methods failed")
} }
// tryProvider queries a single IP API endpoint and parses IPv4 results.
func tryProvider(url string, client *http.Client) (net.IP, error) { func tryProvider(url string, client *http.Client) (net.IP, error) {
resp, err := client.Get(url) resp, err := client.Get(url)
if err != nil { if err != nil {
return nil, fmt.Errorf("network error (%s): %w", url, err) return nil, err
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("HTTP %d (%s)", resp.StatusCode, url) return nil, fmt.Errorf("HTTP %d (%s)", resp.StatusCode, url)
} }
// Some APIs return plain text, others JSON // JSON-based API fallback
var result struct { var j struct{ IP string `json:"ip"` }
IP string `json:"ip"`
}
// Try decode JSON if err := json.NewDecoder(resp.Body).Decode(&j); err == nil && j.IP != "" {
if err := json.NewDecoder(resp.Body).Decode(&result); err == nil && result.IP != "" { ip := net.ParseIP(j.IP)
ip := net.ParseIP(result.IP) if ip != nil && ip.To4() != nil {
if ip != nil && ip.To4() != nil { return ip, nil
return ip, nil }
} }
}
// Fallback: plain text // Plain text fallback → must re-fetch body
resp2, err := client.Get(url) resp2, err := client.Get(url)
if err == nil { if err != nil {
defer resp2.Body.Close() return nil, err
buf := make([]byte, 64) }
n, _ := resp2.Body.Read(buf) defer resp2.Body.Close()
ip := net.ParseIP(string(buf[:n]))
if ip != nil && ip.To4() != nil {
return ip, nil
}
}
return nil, fmt.Errorf("no valid IP found from %s", url) buf := make([]byte, 64)
n, _ := resp2.Body.Read(buf)
ip := net.ParseIP(strings.TrimSpace(string(buf[:n])))
if ip != nil && ip.To4() != nil {
return ip, nil
}
return nil, fmt.Errorf("invalid response from %s", url)
}
// -------------------------------
// GEO LOOKUP USING ipwho.is
// -------------------------------
func lookupIP(ip string) (city string, province string, lat float64, lon float64, err error) {
url := fmt.Sprintf("https://ipwho.is/%s", ip)
client := &http.Client{Timeout: 6 * time.Second}
resp, err := client.Get(url)
if err != nil {
return "", "", 0, 0, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", "", 0, 0, fmt.Errorf("ipwho.is returned HTTP %d", resp.StatusCode)
}
var data struct {
Success bool `json:"success"`
City string `json:"city"`
Region string `json:"region"`
Lat float64 `json:"latitude"`
Lon float64 `json:"longitude"`
Message string `json:"message"`
}
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
return "", "", 0, 0, fmt.Errorf("decode error: %w", err)
}
if !data.Success {
return "", "", 0, 0, fmt.Errorf("ipwho.is error: %s", data.Message)
}
// Clean fields
city = strings.TrimSpace(data.City)
province = strings.TrimSpace(data.Region)
if city == "" {
city = "Unknown"
}
if province == "" {
province = "Unknown"
}
return city, province, data.Lat, data.Lon, nil
} }

View File

@ -1,137 +0,0 @@
package geo
import (
"archive/tar"
"compress/gzip"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"git.leaktechnologies.dev/Leak_Technologies/Skyfeed/internal/config"
)
const (
ipdbFileName = "GeoLite2-City.mmdb"
keyFileName = "maxmind.key"
)
// EnsureIPDBUpToDate checks the local MaxMind database and refreshes monthly.
func EnsureIPDBUpToDate() error {
dbPath := filepath.Join(config.DataDir, ipdbFileName)
info, err := os.Stat(dbPath)
if os.IsNotExist(err) {
fmt.Println("[geo] No IP database found, downloading...")
return updateIPDB(dbPath)
}
if err != nil {
return fmt.Errorf("unable to check IP DB: %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] IP database is older than this month, refreshing...")
return updateIPDB(dbPath)
}
fmt.Println("[geo] IP database is current.")
return nil
}
// ForceUpdateIPDB forces an immediate refresh.
func ForceUpdateIPDB() error {
dbPath := filepath.Join(config.DataDir, ipdbFileName)
fmt.Println("[geo] Forcing IP database update...")
return updateIPDB(dbPath)
}
// updateIPDB downloads and extracts the official GeoLite2 City database using your MaxMind key.
func updateIPDB(dest string) error {
keyPath := filepath.Join(config.ConfigDir, keyFileName)
keyBytes, err := os.ReadFile(keyPath)
if err != nil {
return fmt.Errorf("[geo] Missing MaxMind license key.\nPlease run:\n echo \"YOUR_KEY\" > %s", keyPath)
}
key := strings.TrimSpace(string(keyBytes))
url := fmt.Sprintf("https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-City&license_key=%s&suffix=tar.gz", key)
tmpTar := dest + ".tar.gz"
if err := downloadFile(url, tmpTar); err != nil {
return fmt.Errorf("failed to download GeoLite2 archive: %w", err)
}
defer os.Remove(tmpTar)
if err := extractMMDB(tmpTar, dest); err != nil {
return fmt.Errorf("failed to extract mmdb: %w", err)
}
fmt.Println("[geo] IP database updated successfully →", dest)
return nil
}
// downloadFile streams a file from URL to disk.
func downloadFile(url, dest string) error {
resp, err := http.Get(url)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("unexpected HTTP status: %s", resp.Status)
}
out, err := os.Create(dest)
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, resp.Body)
return err
}
// extractMMDB extracts the .mmdb file from a tar.gz archive.
func extractMMDB(src, dest string) error {
f, err := os.Open(src)
if err != nil {
return err
}
defer f.Close()
gz, err := gzip.NewReader(f)
if err != nil {
return err
}
defer gz.Close()
tr := tar.NewReader(gz)
for {
h, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return err
}
if filepath.Ext(h.Name) == ".mmdb" {
out, err := os.Create(dest)
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, tr)
return err
}
}
return fmt.Errorf("no .mmdb found in archive")
}

10
internal/geo/towns.go Normal file
View File

@ -0,0 +1,10 @@
package geo
// Town represents a Canadian populated place.
// Shared by both the updater and lookup system.
type Town struct {
Name string `json:"name"`
Province string `json:"province"`
Lat float64 `json:"lat"`
Lon float64 `json:"lon"`
}

View File

@ -1,56 +1,50 @@
package geo package geo
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"math" "math"
"os" "os"
"path/filepath" "path/filepath"
"git.leaktechnologies.dev/Leak_Technologies/Skyfeed/internal/config" "git.leaktechnologies.dev/Leak_Technologies/Skyfeed/internal/config"
) )
// Town represents a Canadian town record (loaded from towns.json).
type Town struct {
Name string `json:"name"`
Province string `json:"province"`
Lat float64 `json:"lat"`
Lon float64 `json:"lon"`
}
// FindNearestTown loads the cached towns.json and finds the closest town to given coordinates. // FindNearestTown loads the cached towns.json and finds the closest town to given coordinates.
func FindNearestTown(lat, lon float64) (Town, error) { func FindNearestTown(lat, lon float64) (Town, error) {
townsPath := filepath.Join(config.DataDir, "towns.json") townsPath := filepath.Join(config.DataDir, "towns.json")
data, err := os.ReadFile(townsPath) data, err := os.ReadFile(townsPath)
if err != nil { if err != nil {
return Town{}, fmt.Errorf("failed to read town index: %w", err) return Town{}, fmt.Errorf("failed to read town index: %w", err)
} }
var towns []Town var towns []Town
if err := json.Unmarshal(data, &towns); err != nil { if err := json.Unmarshal(data, &towns); err != nil {
return Town{}, fmt.Errorf("failed to parse towns.json: %w", err) return Town{}, fmt.Errorf("failed to parse towns.json: %w", err)
} }
if len(towns) == 0 { if len(towns) == 0 {
return Town{}, fmt.Errorf("no towns found in index") return Town{}, fmt.Errorf("no towns found in index")
} }
minDist := math.MaxFloat64 minDist := math.MaxFloat64
var nearest Town var nearest Town
for _, t := range towns { for _, t := range towns {
d := Haversine(lat, lon, t.Lat, t.Lon) // ✅ use shared helper from stations.go d := Haversine(lat, lon, t.Lat, t.Lon)
if d < minDist { if d < minDist {
minDist = d minDist = d
nearest = t nearest = t
} }
} }
if nearest.Name == "" { if nearest.Name == "" {
return Town{}, fmt.Errorf("no nearby town found") return Town{}, fmt.Errorf("no nearby town found")
} }
fmt.Printf("[geo] Nearest town: %s, %s (%.2f km)\n", nearest.Name, nearest.Province, minDist) fmt.Printf("[geo] Nearest town: %s, %s (%.2f km)\n",
return nearest, nil nearest.Name, nearest.Province, minDist)
return nearest, nil
} }

View File

@ -1,135 +1,123 @@
package geo package geo
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"time" "time"
"git.leaktechnologies.dev/Leak_Technologies/Skyfeed/internal/config" "git.leaktechnologies.dev/Leak_Technologies/Skyfeed/internal/config"
) )
const ( const (
// Official Geographical Names Board of Canada WFS API // WORKING GeoGratis endpoint for all populated places (PPL)
// Docs: https://www.nrcan.gc.ca/earth-sciences/geography/geographical-names-board-canada/download-geographical-names-data/10786 gnbcAPIURL = "https://geogratis.gc.ca/services/geoname/en/geonames.json?feature_code=PPL"
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"
townsFile = "towns.json" maxFetchTime = 5 * time.Minute
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). // EnsureTownIndexUpToDate checks if the towns index needs updating (monthly).
func EnsureTownIndexUpToDate() error { func EnsureTownIndexUpToDate() error {
dest := filepath.Join(config.DataDir, townsFile) dest := filepath.Join(config.DataDir, townsFile)
info, err := os.Stat(dest) info, err := os.Stat(dest)
if os.IsNotExist(err) { if os.IsNotExist(err) {
fmt.Println("[geo] No town index found, downloading...") fmt.Println("[geo] No town index found, downloading...")
return downloadTownIndex(dest) return downloadTownIndex(dest)
} }
if err != nil { if err != nil {
return fmt.Errorf("unable to check town index: %w", err) return fmt.Errorf("unable to check town index: %w", err)
} }
modTime := info.ModTime().UTC() modTime := info.ModTime().UTC()
now := time.Now().UTC() now := time.Now().UTC()
firstOfMonth := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC) firstOfMonth := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
if modTime.Before(firstOfMonth) { if modTime.Before(firstOfMonth) {
fmt.Println("[geo] Town index is older than this month, refreshing...") fmt.Println("[geo] Town index is older than this month, refreshing...")
return downloadTownIndex(dest) return downloadTownIndex(dest)
} }
fmt.Println("[geo] Town index is current.") fmt.Println("[geo] Town index is current.")
return nil return nil
} }
// ForceUpdateTownIndex forces an immediate rebuild. // ForceUpdateTownIndex forces an immediate rebuild.
func ForceUpdateTownIndex() error { func ForceUpdateTownIndex() error {
dest := filepath.Join(config.DataDir, townsFile) dest := filepath.Join(config.DataDir, townsFile)
fmt.Println("[geo] Forcing town index update...") fmt.Println("[geo] Forcing town index update...")
return downloadTownIndex(dest) return downloadTownIndex(dest)
} }
// downloadTownIndex fetches and stores the Canadian town dataset.
func downloadTownIndex(dest string) error { func downloadTownIndex(dest string) error {
client := &http.Client{Timeout: maxFetchTime} client := &http.Client{Timeout: maxFetchTime}
fmt.Println("[geo] Fetching town data from GNBC WFS API...") fmt.Println("[geo] Fetching town data from GeoGratis...")
resp, err := client.Get(gnbcAPIURL) resp, err := client.Get(gnbcAPIURL)
if err != nil { if err != nil {
return fmt.Errorf("failed to fetch town dataset: %w", err) return fmt.Errorf("failed to fetch town dataset: %w", err)
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
return fmt.Errorf("unexpected HTTP status: %s", resp.Status) return fmt.Errorf("unexpected HTTP status: %s", resp.Status)
} }
raw, err := io.ReadAll(resp.Body) raw, err := io.ReadAll(resp.Body)
if err != nil { if err != nil {
return fmt.Errorf("failed to read GNBC response: %w", err) return fmt.Errorf("failed to read GeoGratis response: %w", err)
} }
towns, err := parseGNBCJSON(raw) towns, err := parseGNBCJSON(raw)
if err != nil { if err != nil {
return fmt.Errorf("failed to parse GNBC JSON: %w", err) return fmt.Errorf("failed to parse GeoGratis JSON: %w", err)
} }
data, err := json.MarshalIndent(towns, "", " ") data, err := json.MarshalIndent(towns, "", " ")
if err != nil { if err != nil {
return fmt.Errorf("failed to encode towns: %w", err) return fmt.Errorf("failed to encode towns.json: %w", err)
} }
if err := os.WriteFile(dest, data, 0644); err != nil { if err := os.WriteFile(dest, data, 0644); err != nil {
return fmt.Errorf("failed to write %s: %w", dest, err) return fmt.Errorf("failed to write %s: %w", dest, err)
} }
fmt.Printf("[geo] Town index updated → %s (%d towns)\n", dest, len(towns)) fmt.Printf("[geo] Town index updated → %s (%d towns)\n", dest, len(towns))
return nil return nil
} }
// parseGNBCJSON extracts relevant town info from the GNBC GeoJSON. // Uses GeoGratis geonames.json structure
func parseGNBCJSON(data []byte) ([]TownRecord, error) { func parseGNBCJSON(data []byte) ([]Town, error) {
var response struct { var response struct {
Features []struct { Items []struct {
Properties struct { Name string `json:"name"`
Name string `json:"name"` ProvinceCode string `json:"province_code"`
Province string `json:"province"` Latitude float64 `json:"latitude"`
Latitude float64 `json:"latitude"` Longitude float64 `json:"longitude"`
Longitude float64 `json:"longitude"` } `json:"items"`
} `json:"properties"` }
} `json:"features"`
}
if err := json.Unmarshal(data, &response); err != nil { if err := json.Unmarshal(data, &response); err != nil {
return nil, fmt.Errorf("invalid GNBC JSON: %w", err) return nil, fmt.Errorf("invalid GeoGratis JSON: %w", err)
} }
var towns []TownRecord var towns []Town
for _, f := range response.Features { for _, item := range response.Items {
p := f.Properties if item.Name == "" || item.ProvinceCode == "" {
if p.Name == "" || p.Province == "" { continue
continue }
}
towns = append(towns, TownRecord{
Name: p.Name,
Province: p.Province,
Lat: p.Latitude,
Lon: p.Longitude,
})
}
return towns, nil towns = append(towns, Town{
Name: item.Name,
Province: item.ProvinceCode,
Lat: item.Latitude,
Lon: item.Longitude,
})
}
return towns, nil
} }

BIN
skyfeed

Binary file not shown.