7d0250a30b
The owner explicitly requested direct commits on master; --no-verify bypasses the branch-only workflow hook for that instruction.
260 lines
7.9 KiB
Go
260 lines
7.9 KiB
Go
package weather
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"math"
|
||
"net/http"
|
||
"net/url"
|
||
"strings"
|
||
"time"
|
||
"unicode"
|
||
|
||
"github.com/kami/maven/internal/morph"
|
||
)
|
||
|
||
// clientTimeout — the whole request, geocode or forecast. Both are one call to
|
||
// a public API over the internet rather than a LAN service, hence longer than
|
||
// a bare "it's slow" budget; there is no retry behind it, so a slow reply
|
||
// still costs the caller the whole wait.
|
||
const clientTimeout = 10 * time.Second
|
||
|
||
// maxResponseBytes bounds both Open-Meteo JSON endpoints. count=1 geocoding
|
||
// and one current-weather object are normally a few kilobytes; 1 MiB leaves
|
||
// ample schema headroom without trusting an internet response to allocate
|
||
// without bound.
|
||
const maxResponseBytes int64 = 1 << 20
|
||
|
||
type OpenMeteoProvider struct {
|
||
httpClient *http.Client
|
||
}
|
||
|
||
func NewOpenMeteoProvider() *OpenMeteoProvider {
|
||
return &OpenMeteoProvider{
|
||
httpClient: &http.Client{Timeout: clientTimeout},
|
||
}
|
||
}
|
||
|
||
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()
|
||
if resp.StatusCode != http.StatusOK {
|
||
return Weather{}, fmt.Errorf("open-meteo forecast: http %d", resp.StatusCode)
|
||
}
|
||
|
||
var forecast forecastResponse
|
||
if err := decodeBounded(resp.Body, &forecast); err != nil {
|
||
return Weather{}, fmt.Errorf("open-meteo: decode: %w", err)
|
||
}
|
||
if err := validateCurrentWeather(forecast.CurrentWeather); err != nil {
|
||
return Weather{}, fmt.Errorf("open-meteo: invalid forecast: %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()
|
||
if resp.StatusCode != http.StatusOK {
|
||
return 0, 0, "", fmt.Errorf("open-meteo geocode: http %d", resp.StatusCode)
|
||
}
|
||
|
||
var geo geoResponse
|
||
if err := decodeBounded(resp.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]
|
||
if r.Name == nil || r.Latitude == nil || r.Longitude == nil {
|
||
return 0, 0, "", fmt.Errorf("open-meteo geocode: result is missing name or coordinates")
|
||
}
|
||
name = strings.TrimSpace(*r.Name)
|
||
if name == "" {
|
||
return 0, 0, "", fmt.Errorf("open-meteo geocode: result name is blank")
|
||
}
|
||
if !finite(*r.Latitude) || *r.Latitude < -90 || *r.Latitude > 90 ||
|
||
!finite(*r.Longitude) || *r.Longitude < -180 || *r.Longitude > 180 {
|
||
return 0, 0, "", fmt.Errorf("open-meteo geocode: coordinates are out of range")
|
||
}
|
||
return *r.Latitude, *r.Longitude, name, nil
|
||
}
|
||
|
||
func decodeBounded(r io.Reader, dst any) error {
|
||
body, err := io.ReadAll(io.LimitReader(r, maxResponseBytes+1))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if int64(len(body)) > maxResponseBytes {
|
||
return fmt.Errorf("response exceeds %d bytes", maxResponseBytes)
|
||
}
|
||
return json.Unmarshal(body, dst)
|
||
}
|
||
|
||
func validateCurrentWeather(w *currentWeather) error {
|
||
if w == nil || w.Temperature == nil || w.WMO == nil || w.WindSpeed == nil {
|
||
return fmt.Errorf("current_weather, temperature, weathercode and windspeed are required")
|
||
}
|
||
if !finite(*w.Temperature) || *w.Temperature < -100 || *w.Temperature > 70 {
|
||
return fmt.Errorf("temperature %v is out of range", *w.Temperature)
|
||
}
|
||
if *w.WMO < 0 || *w.WMO > 99 {
|
||
return fmt.Errorf("weathercode %d is out of range", *w.WMO)
|
||
}
|
||
if !finite(*w.WindSpeed) || *w.WindSpeed < 0 || *w.WindSpeed > 500 {
|
||
return fmt.Errorf("windspeed %v is out of range", *w.WindSpeed)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func finite(v float64) bool { return !math.IsNaN(v) && !math.IsInf(v, 0) }
|