repos
/ orchard main

orchard

mirror

Every site I host, in one repo, along with the Cloudflare Tunnel and Caddy that front them. It's all Go, Vite, and SQLite, and it runs on a desktop at home with nothing listening on an inbound port.

blogbuncaddycloudflare-tunneldockergogolanghomelabhtml-templatemonorepoself-hostedseosqlitestatic-sitetypstuptime-monitoringviteweb-analytics

11.2 KB · 391 lines · Go Raw History
  1package tools
  2
  3import (
  4	"context"
  5	"fmt"
  6	"math"
  7	"net/url"
  8	"sort"
  9	"strings"
 10	"time"
 11)
 12
 13// The readings behind a widget. These are called from the page rather than from
 14// a turn, so a chart can change range without the model being involved and a
 15// reopened conversation draws today's numbers.
 16
 17// Range is one of the four spans the chart offers. Yahoo wants a range and an
 18// interval together and the pair has to be chosen rather than derived: a year
 19// at five minute bars is 20,000 points nobody can see and a day at daily bars
 20// is one.
 21type Range struct {
 22	Key      string
 23	Label    string
 24	Range    string
 25	Interval string
 26}
 27
 28var Ranges = []Range{
 29	{"1d", "1D", "1d", "5m"},
 30	{"1w", "1W", "5d", "30m"},
 31	{"1m", "1M", "1mo", "1d"},
 32	{"1y", "1Y", "1y", "1d"},
 33}
 34
 35func RangeByKey(k string) Range {
 36	for _, r := range Ranges {
 37		if r.Key == k {
 38			return r
 39		}
 40	}
 41	return Ranges[0]
 42}
 43
 44type Point struct {
 45	T int64   `json:"t"`
 46	C float64 `json:"c"`
 47}
 48
 49type Series struct {
 50	Symbol   string  `json:"symbol"`
 51	Name     string  `json:"name,omitempty"`
 52	Currency string  `json:"currency,omitempty"`
 53	Range    string  `json:"range"`
 54	Price    float64 `json:"price"`
 55	Previous float64 `json:"previous"`
 56	Change   float64 `json:"change"`
 57	Percent  float64 `json:"percent"`
 58	Points   []Point `json:"points"`
 59
 60	// Intraday is the one range whose baseline means yesterday's close rather
 61	// than the first bar drawn, which is what the dotted rule on the chart is.
 62	Intraday bool `json:"intraday"`
 63}
 64
 65// Ticker reads one symbol over one range. Symbols arrive already in Yahoo's
 66// form, indexes as ^GSPC and futures as GC=F and crypto as BTC-USD, because the
 67// markets tool resolved them before the widget was ever recorded.
 68func Ticker(ctx context.Context, d *Deps, symbol, rangeKey string) (Series, error) {
 69	r := RangeByKey(rangeKey)
 70	var out chartPayload
 71	u := fmt.Sprintf("https://query1.finance.yahoo.com/v8/finance/chart/%s?range=%s&interval=%s",
 72		url.PathEscape(symbol), r.Range, r.Interval)
 73	if err := getJSON(ctx, d, u, &out); err != nil {
 74		return Series{}, err
 75	}
 76	return buildSeries(out, r, symbol)
 77}
 78
 79// chartPayload is Yahoo's chart response, only the fields the panel draws.
 80type chartPayload struct {
 81	Chart struct {
 82		Result []struct {
 83			Meta struct {
 84				Currency  string  `json:"currency"`
 85				Symbol    string  `json:"symbol"`
 86				Price     float64 `json:"regularMarketPrice"`
 87				PrevClose float64 `json:"chartPreviousClose"`
 88				ShortName string  `json:"shortName"`
 89				LongName  string  `json:"longName"`
 90			} `json:"meta"`
 91			Timestamp  []int64 `json:"timestamp"`
 92			Indicators struct {
 93				Quote []struct {
 94					Close []*float64 `json:"close"`
 95				} `json:"quote"`
 96			} `json:"indicators"`
 97		} `json:"result"`
 98	} `json:"chart"`
 99}
100
101// buildSeries is the arithmetic, split from the fetch so the part that decides
102// what a percentage means can be tested without a network.
103func buildSeries(out chartPayload, r Range, symbol string) (Series, error) {
104	if len(out.Chart.Result) == 0 {
105		return Series{}, fmt.Errorf("no chart for %q", symbol)
106	}
107	res := out.Chart.Result[0]
108
109	s := Series{
110		Symbol: strings.ToUpper(symbol), Currency: res.Meta.Currency,
111		Range: r.Key, Price: res.Meta.Price, Previous: res.Meta.PrevClose,
112		Intraday: r.Key == "1d",
113	}
114	s.Name = res.Meta.LongName
115	if s.Name == "" {
116		s.Name = res.Meta.ShortName
117	}
118
119	// A gap in the series is a bar the exchange never printed, so it is dropped
120	// rather than zeroed. A zero would draw a spike to the floor of the chart.
121	if len(res.Indicators.Quote) > 0 {
122		cl := res.Indicators.Quote[0].Close
123		for i := range cl {
124			if cl[i] == nil || i >= len(res.Timestamp) {
125				continue
126			}
127			s.Points = append(s.Points, Point{T: res.Timestamp[i], C: round4(*cl[i])})
128		}
129	}
130	if len(s.Points) == 0 {
131		return Series{}, fmt.Errorf("no prices for %q over %s", symbol, r.Label)
132	}
133
134	// The last bar is the price when the quote field is empty, which is what
135	// happens on an index and on anything out of hours.
136	if s.Price == 0 {
137		s.Price = s.Points[len(s.Points)-1].C
138	}
139	// Over a longer span the change is measured from the first bar on the
140	// chart, since "up 18% this year" means against a year ago and not against
141	// yesterday. Only the intraday chart is measured from the previous close.
142	base := s.Previous
143	if !s.Intraday || base == 0 {
144		base = s.Points[0].C
145		if !s.Intraday {
146			s.Previous = base
147		}
148	}
149	if base > 0 {
150		s.Change = round4(s.Price - base)
151		s.Percent = round2((s.Price - base) / base * 100)
152	}
153	return s, nil
154}
155
156// ---------------------------------------------------------------- weather
157
158type Day struct {
159	Date      string  `json:"date"`
160	Weekday   string  `json:"weekday"`
161	HighF     float64 `json:"high_f"`
162	LowF      float64 `json:"low_f"`
163	FeelsHigh float64 `json:"feels_high_f"`
164	FeelsLow  float64 `json:"feels_low_f"`
165	PrecipPct float64 `json:"precip_pct"`
166	UV        float64 `json:"uv"`
167	Code      int     `json:"code"`
168	Summary   string  `json:"summary"`
169}
170
171type Report struct {
172	Place string `json:"place"`
173	Days  []Day  `json:"days"`
174
175	NowF     float64 `json:"now_f"`
176	FeelsF   float64 `json:"feels_f"`
177	Humidity float64 `json:"humidity"`
178	WindMPH  float64 `json:"wind_mph"`
179	Code     int     `json:"code"`
180	Summary  string  `json:"summary"`
181
182	AQI     float64 `json:"aqi"`
183	AQIBand string  `json:"aqi_band,omitempty"`
184	PM25    float64 `json:"pm25"`
185	HasAir  bool    `json:"has_air"`
186
187	Pollen     float64 `json:"pollen"`
188	PollenBand string  `json:"pollen_band,omitempty"`
189	PollenTop  string  `json:"pollen_top,omitempty"`
190	HasPollen  bool    `json:"has_pollen"`
191}
192
193// Forecast is the daily outlook plus the two readings that are not in it. The air
194// call and the pollen call are separate hosts and either can be missing without
195// the panel being wrong, so each failure only costs its own tile.
196func Forecast(ctx context.Context, d *Deps, lat, lon float64, place, zip, country string, days int) (Report, error) {
197	if days < 1 || days > 14 {
198		days = 7
199	}
200	var w struct {
201		Current struct {
202			Temp     float64 `json:"temperature_2m"`
203			Feels    float64 `json:"apparent_temperature"`
204			Humidity float64 `json:"relative_humidity_2m"`
205			Wind     float64 `json:"wind_speed_10m"`
206			Code     int     `json:"weather_code"`
207		} `json:"current"`
208		Daily struct {
209			Time     []string  `json:"time"`
210			Max      []float64 `json:"temperature_2m_max"`
211			Min      []float64 `json:"temperature_2m_min"`
212			FeelsMax []float64 `json:"apparent_temperature_max"`
213			FeelsMin []float64 `json:"apparent_temperature_min"`
214			Precip   []float64 `json:"precipitation_probability_max"`
215			UV       []float64 `json:"uv_index_max"`
216			Code     []int     `json:"weather_code"`
217		} `json:"daily"`
218	}
219	u := fmt.Sprintf("https://api.open-meteo.com/v1/forecast?latitude=%f&longitude=%f"+
220		"&current=temperature_2m,apparent_temperature,relative_humidity_2m,wind_speed_10m,weather_code"+
221		"&daily=temperature_2m_max,temperature_2m_min,apparent_temperature_max,apparent_temperature_min,"+
222		"precipitation_probability_max,uv_index_max,weather_code"+
223		"&temperature_unit=fahrenheit&wind_speed_unit=mph&timezone=auto&forecast_days=%d", lat, lon, days)
224	if err := getJSON(ctx, d, u, &w); err != nil {
225		return Report{}, err
226	}
227
228	rep := Report{Place: place, NowF: round1(w.Current.Temp), FeelsF: round1(w.Current.Feels),
229		Humidity: round1(w.Current.Humidity), WindMPH: round1(w.Current.Wind),
230		Code: w.Current.Code, Summary: wmo(w.Current.Code)}
231
232	for i := range w.Daily.Time {
233		dd := Day{Date: w.Daily.Time[i]}
234		if t, e := time.Parse("2006-01-02", w.Daily.Time[i]); e == nil {
235			dd.Weekday = t.Format("Mon")
236		}
237		at := func(s []float64) float64 {
238			if i < len(s) {
239				return round1(s[i])
240			}
241			return 0
242		}
243		dd.HighF, dd.LowF = at(w.Daily.Max), at(w.Daily.Min)
244		dd.FeelsHigh, dd.FeelsLow = at(w.Daily.FeelsMax), at(w.Daily.FeelsMin)
245		dd.PrecipPct, dd.UV = at(w.Daily.Precip), at(w.Daily.UV)
246		if i < len(w.Daily.Code) {
247			dd.Code = w.Daily.Code[i]
248			dd.Summary = wmo(dd.Code)
249		}
250		rep.Days = append(rep.Days, dd)
251	}
252
253	rep.airQuality(ctx, d, lat, lon)
254	rep.pollen(ctx, d, zip, country)
255	return rep, nil
256}
257
258func (rep *Report) airQuality(ctx context.Context, d *Deps, lat, lon float64) {
259	var air struct {
260		Current struct {
261			AQI  *float64 `json:"us_aqi"`
262			PM25 *float64 `json:"pm2_5"`
263		} `json:"current"`
264	}
265	u := fmt.Sprintf("https://air-quality-api.open-meteo.com/v1/air-quality?latitude=%f&longitude=%f"+
266		"&current=us_aqi,pm2_5&timezone=auto", lat, lon)
267	if err := getJSON(ctx, d, u, &air); err != nil || air.Current.AQI == nil {
268		return
269	}
270	rep.AQI, rep.HasAir = math.Round(*air.Current.AQI), true
271	rep.AQIBand = aqiBand(rep.AQI)
272	if air.Current.PM25 != nil {
273		rep.PM25 = round1(*air.Current.PM25)
274	}
275}
276
277// pollen is pollen.com, which is the source dash settled on for the same
278// reason: open-meteo carries pollen for Europe only and returns null for every
279// US location. It is keyed on a zip, which the geocode already knew, and it
280// refuses a request that arrives without a Referer.
281func (rep *Report) pollen(ctx context.Context, d *Deps, zip, country string) {
282	if zip == "" || (country != "" && country != "US") {
283		return
284	}
285	var p struct {
286		Location struct {
287			Periods []struct {
288				Type     string  `json:"Type"`
289				Index    float64 `json:"Index"`
290				Triggers []struct {
291					Name string `json:"Name"`
292				} `json:"Triggers"`
293			} `json:"periods"`
294		} `json:"Location"`
295	}
296	u := "https://www.pollen.com/api/forecast/current/pollen/" + url.PathEscape(zip)
297	if err := getJSONHeaders(ctx, d, u, map[string]string{
298		"Referer": "https://www.pollen.com/forecast/current/pollen/" + zip,
299	}, &p); err != nil {
300		return
301	}
302	for _, per := range p.Location.Periods {
303		if !strings.EqualFold(per.Type, "Today") {
304			continue
305		}
306		rep.Pollen, rep.HasPollen = round1(per.Index), true
307		rep.PollenBand = pollenBand(per.Index)
308		names := make([]string, 0, len(per.Triggers))
309		for _, t := range per.Triggers {
310			names = append(names, t.Name)
311		}
312		sort.Strings(names)
313		rep.PollenTop = strings.Join(names, ", ")
314		return
315	}
316}
317
318// pollenBand is pollen.com's own 0 to 12 scale, the same bands dash reads it on.
319func pollenBand(i float64) string {
320	switch {
321	case i < 2.4:
322		return "low"
323	case i < 4.8:
324		return "low-medium"
325	case i < 7.2:
326		return "medium"
327	case i < 9.7:
328		return "medium-high"
329	default:
330		return "high"
331	}
332}
333
334// aqiBand is the EPA's own naming for the US AQI breakpoints.
335func aqiBand(v float64) string {
336	switch {
337	case v <= 50:
338		return "good"
339	case v <= 100:
340		return "moderate"
341	case v <= 150:
342		return "unhealthy for some"
343	case v <= 200:
344		return "unhealthy"
345	case v <= 300:
346		return "very unhealthy"
347	default:
348		return "hazardous"
349	}
350}
351
352// wmo names the weather code open-meteo answers with. The list is theirs and
353// the wording is shortened to what fits a tile.
354func wmo(c int) string {
355	switch c {
356	case 0:
357		return "clear"
358	case 1:
359		return "mostly clear"
360	case 2:
361		return "partly cloudy"
362	case 3:
363		return "overcast"
364	case 45, 48:
365		return "fog"
366	case 51, 53, 55:
367		return "drizzle"
368	case 56, 57:
369		return "freezing drizzle"
370	case 61, 63, 65:
371		return "rain"
372	case 66, 67:
373		return "freezing rain"
374	case 71, 73, 75:
375		return "snow"
376	case 77:
377		return "snow grains"
378	case 80, 81, 82:
379		return "showers"
380	case 85, 86:
381		return "snow showers"
382	case 95:
383		return "thunderstorms"
384	case 96, 99:
385		return "thunderstorms, hail"
386	}
387	return ""
388}
389
390func round2(v float64) float64 { return math.Round(v*100) / 100 }