c62c7034fa
locationCandidates reversed endings by hand to turn "в Казани" into the nominative the geocoder wants. internal/morph knows the answer for the places it has, so it goes first and the reversals stay behind it for the ones it does not: "Твери" and "Перми" come back unchanged. The four-rune floor was there to stop a two-letter stem, so it now tests the stem instead. "Уфе" was under the floor and "Уфа" was never tried. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
207 lines
5.6 KiB
Go
207 lines
5.6 KiB
Go
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
|
||
}
|