package weather import ( "context" "encoding/json" "fmt" "io" "net/http" "net/url" "time" ) type OpenMeteoProvider struct { httpClient *http.Client } func NewOpenMeteoProvider() *OpenMeteoProvider { return &OpenMeteoProvider{ httpClient: &http.Client{Timeout: 10 * time.Second}, } } func (p *OpenMeteoProvider) SetClient(c *http.Client) { p.httpClient = c } type geoResult struct { Name string `json:"name"` Latitude float64 `json:"latitude"` Longitude float64 `json:"longitude"` Country string `json:"country"` } type geoResponse struct { Results []geoResult `json:"results"` } type currentWeather struct { Temperature float64 `json:"temperature"` WMO int `json:"weathercode"` WindSpeed float64 `json:"windspeed"` } type forecastResponse struct { CurrentWeather currentWeather `json:"current_weather"` } var wmoCodes = map[int]string{ 0: "ясно", 1: "преимущественно ясно", 2: "облачно", 3: "пасмурно", 45: "туман", 51: "морось", 61: "дождь", 71: "снег", 95: "гроза", } func (p *OpenMeteoProvider) CurrentWeather(ctx context.Context, location string) (Weather, error) { lat, lon, name, err := p.geocode(ctx, location) if err != nil { return Weather{}, fmt.Errorf("open-meteo: geocode: %w", err) } u := fmt.Sprintf("https://api.open-meteo.com/v1/forecast?latitude=%.4f&longitude=%.4f¤t_weather=true", lat, lon) req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) if err != nil { return Weather{}, fmt.Errorf("open-meteo: request: %w", err) } resp, err := p.httpClient.Do(req) if err != nil { return Weather{}, fmt.Errorf("open-meteo: http: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return Weather{}, fmt.Errorf("open-meteo: read: %w", err) } var forecast forecastResponse if err := json.Unmarshal(body, &forecast); err != nil { return Weather{}, fmt.Errorf("open-meteo: decode: %w", err) } condition := wmoCodes[forecast.CurrentWeather.WMO] if condition == "" { condition = "неизвестно" } return Weather{ Location: name, Temperature: forecast.CurrentWeather.Temperature, Condition: condition, }, nil } // locationCandidates — the spellings to try for a place taken out of a spoken // sentence, in order. He says "какая погода в Казани", so the word arrives in // the prepositional case and the geocoder wants the nominative (Vikunja #421). // // Two cheap reversals cover most of what he says: a final "е" is usually a // nominative "а" (Москве → Москва) or nothing at all (Лондоне → Лондон), and a // final "и" is usually a soft sign (Казани → Казань). Indeclinable names — // Тбилиси, Сочи, Осло — are already nominative and the first candidate answers. // // Nothing here is a guess about the weather: a wrong candidate finds no city // and the caller says so. It only decides which strings are worth asking about. func locationCandidates(location string) []string { out := []string{location} add := func(s string) { if s == "" || s == location { return } for _, seen := range out { if seen == s { return } } out = append(out, s) } r := []rune(location) if len(r) < 4 { return out } stem := string(r[:len(r)-1]) switch r[len(r)-1] { case 'е', 'Е': add(stem + "а") add(stem) case 'и', 'И': add(stem + "ь") add(stem) case 'у', 'У', 'ю', 'Ю': add(stem + "а") } return out } func (p *OpenMeteoProvider) geocode(ctx context.Context, location string) (lat, lon float64, name string, err error) { for _, cand := range locationCandidates(location) { lat, lon, name, err = p.geocodeOne(ctx, cand) if err == nil { return lat, lon, name, nil } } return 0, 0, "", err } func (p *OpenMeteoProvider) geocodeOne(ctx context.Context, location string) (lat, lon float64, name string, err error) { u := fmt.Sprintf("https://geocoding-api.open-meteo.com/v1/search?name=%s&count=1&language=ru&format=json", url.QueryEscape(location)) req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) if err != nil { return 0, 0, "", err } resp, err := p.httpClient.Do(req) if err != nil { return 0, 0, "", err } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return 0, 0, "", err } var geo geoResponse if err := json.Unmarshal(body, &geo); err != nil { return 0, 0, "", err } if len(geo.Results) == 0 { return 0, 0, "", fmt.Errorf("%w: %q", ErrLocationUnknown, location) } r := geo.Results[0] return r.Latitude, r.Longitude, r.Name, nil }