package weather import ( "context" "encoding/json" "fmt" "io" "net/http" "net/url" "time" "unicode" "github.com/kami/maven/internal/morph" ) 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). // // The dictionary answers first (Vikunja #530). internal/morph lemmatises // "Уфе" to "Уфа" and "Москве" to "Москва", which is the same question this // used to guess at by reversing endings, asked of something that knows. // // The reversals stay behind it, because the dictionary does not know every // place: "Твери" and "Перми" come back unchanged, and a final "и" is usually a // soft sign. A final "е" is usually a nominative "а" (Москве → Москва) or // nothing at all (Лондоне → Лондон). Indeclinable names — Тбилиси, Сочи, Осло — // are already nominative and the first candidate answers, which is why the word // as spoken is always tried before anything derived from it. // // There used to be a four-rune floor here, so "Уфе" was asked as spoken and // "Уфа" was never tried. The floor was there to stop a two-letter stem, and the // stem length is what it now tests. // // 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) } add(titleFirst(morph.Lemma(location))) r := []rune(location) if len(r) < 3 { 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 } // titleFirst restores the leading capital a place name carries. morph.Lemma // answers lowercased, because a lemma is a dictionary entry and the dictionary // has no opinion about proper nouns. func titleFirst(s string) string { r := []rune(s) if len(r) == 0 { return s } return string(unicode.ToUpper(r[0])) + string(r[1:]) } 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 }