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

10.7 KB · 331 lines · Go Raw History
  1package tools
  2
  3import (
  4	"context"
  5	"encoding/json"
  6	"fmt"
  7	"net/url"
  8	"regexp"
  9	"strconv"
 10	"strings"
 11	"time"
 12)
 13
 14// ---------------------------------------------------------------- weather
 15
 16var stateNames = map[string]string{
 17	"nc": "North Carolina", "sc": "South Carolina", "va": "Virginia", "tn": "Tennessee",
 18	"ga": "Georgia", "ny": "New York", "ca": "California", "tx": "Texas", "fl": "Florida",
 19	"wv": "West Virginia", "ky": "Kentucky", "oh": "Ohio", "pa": "Pennsylvania", "md": "Maryland",
 20}
 21
 22var trailingState = regexp.MustCompile(`^(.*?)[,\s]+([A-Za-z]{2})$`)
 23
 24// geocode tries the obvious rewrites before giving up. Open-Meteo wants a clean
 25// place name and a model passes whatever the user typed, so "Yadkin Valley NC"
 26// misses where "Yadkin Valley, North Carolina" hits. That one missing comma was
 27// a real wrong answer during the bakeoff.
 28func geocode(ctx context.Context, d *Deps, place string) (g geo, err error) {
 29	cands := []string{place}
 30	if m := trailingState.FindStringSubmatch(place); m != nil {
 31		if full, ok := stateNames[strings.ToLower(m[2])]; ok {
 32			cands = append(cands, m[1]+", "+full, strings.TrimSpace(m[1]))
 33		}
 34	}
 35	if i := strings.Index(place, ","); i > 0 {
 36		cands = append(cands, strings.TrimSpace(place[:i]))
 37	}
 38	seen := map[string]bool{}
 39	for _, c := range cands {
 40		c = strings.TrimSpace(c)
 41		if c == "" || seen[strings.ToLower(c)] {
 42			continue
 43		}
 44		seen[strings.ToLower(c)] = true
 45		var out struct {
 46			Results []struct {
 47				Latitude  float64  `json:"latitude"`
 48				Longitude float64  `json:"longitude"`
 49				Name      string   `json:"name"`
 50				Admin1    string   `json:"admin1"`
 51				Country   string   `json:"country_code"`
 52				Postcodes []string `json:"postcodes"`
 53			} `json:"results"`
 54		}
 55		u := "https://geocoding-api.open-meteo.com/v1/search?count=1&language=en&name=" + url.QueryEscape(c)
 56		if e := getJSON(ctx, d, u, &out); e != nil {
 57			err = e
 58			continue
 59		}
 60		if len(out.Results) > 0 {
 61			r := out.Results[0]
 62			g = geo{Lat: r.Latitude, Lon: r.Longitude,
 63				Name:    strings.TrimSuffix(r.Name+", "+r.Admin1, ", "),
 64				Country: strings.ToUpper(r.Country)}
 65			if len(r.Postcodes) > 0 {
 66				g.Zip = r.Postcodes[0]
 67			}
 68			return g, nil
 69		}
 70	}
 71	return geo{}, fmt.Errorf("could not find a place called %q", place)
 72}
 73
 74// geo is what one lookup settles. The postcode is carried because pollen.com is
 75// keyed on a US zip and this is the only call that ever knows one, and the
 76// country because that is what says whether asking for pollen is worth a
 77// request at all.
 78type geo struct {
 79	Lat, Lon float64
 80	Name     string
 81	Zip      string
 82	Country  string
 83}
 84
 85var Weather = Tool{
 86	Name:        "weather",
 87	Description: "Daily forecast for a place, up to 14 days out, in Fahrenheit and mph.",
 88	Schema: obj(map[string]any{
 89		"location": str("a town, city or region, like \"Boone, NC\""),
 90		"days":     integer("how many days ahead, 1 to 14, default 7"),
 91	}, "location"),
 92	Run: func(ctx context.Context, d *Deps, a map[string]any) (any, error) {
 93		g, err := geocode(ctx, d, argStr(a, "location"))
 94		if err != nil {
 95			return nil, err
 96		}
 97		lat, lon, name := g.Lat, g.Lon, g.Name
 98		d.Widgets.Add(Widget{Kind: "weather", Place: name, Lat: lat, Lon: lon,
 99			Zip: g.Zip, Country: g.Country})
100		days := int(argNum(a, "days", 7))
101		if days < 1 || days > 14 {
102			days = 7
103		}
104		var w struct {
105			Daily struct {
106				Time    []string  `json:"time"`
107				Max     []float64 `json:"temperature_2m_max"`
108				Min     []float64 `json:"temperature_2m_min"`
109				Precip  []float64 `json:"precipitation_probability_max"`
110				Wind    []float64 `json:"wind_speed_10m_max"`
111				Sunrise []string  `json:"sunrise"`
112				Sunset  []string  `json:"sunset"`
113			} `json:"daily"`
114		}
115		u := fmt.Sprintf("https://api.open-meteo.com/v1/forecast?latitude=%f&longitude=%f"+
116			"&daily=temperature_2m_max,temperature_2m_min,precipitation_probability_max,wind_speed_10m_max,sunrise,sunset"+
117			"&temperature_unit=fahrenheit&wind_speed_unit=mph&timezone=auto&forecast_days=%d", lat, lon, days)
118		if err := getJSON(ctx, d, u, &w); err != nil {
119			return nil, err
120		}
121		type day struct {
122			Date      string  `json:"date"`
123			Weekday   string  `json:"weekday"`
124			HighF     float64 `json:"high_f"`
125			LowF      float64 `json:"low_f"`
126			PrecipPct float64 `json:"precip_chance_pct"`
127			WindMPH   float64 `json:"wind_mph"`
128			Sunset    string  `json:"sunset,omitempty"`
129		}
130		out := make([]day, 0, len(w.Daily.Time))
131		for i := range w.Daily.Time {
132			wd := ""
133			if t, e := time.Parse("2006-01-02", w.Daily.Time[i]); e == nil {
134				wd = t.Format("Monday")
135			}
136			dd := day{Date: w.Daily.Time[i], Weekday: wd, HighF: w.Daily.Max[i],
137				LowF: w.Daily.Min[i], PrecipPct: w.Daily.Precip[i], WindMPH: w.Daily.Wind[i]}
138			if i < len(w.Daily.Sunset) {
139				if t, e := time.Parse("2006-01-02T15:04", w.Daily.Sunset[i]); e == nil {
140					dd.Sunset = t.Format("3:04 PM")
141				}
142			}
143			out = append(out, dd)
144		}
145		return map[string]any{"place": name, "units": "fahrenheit, mph", "days": out}, nil
146	},
147}
148
149// ---------------------------------------------------------------- markets
150
151// alias lets the model say "S&P 500" or "gold" instead of knowing ticker syntax.
152var alias = map[string]string{
153	"s&p 500": "^GSPC", "s&p": "^GSPC", "sp500": "^GSPC", "spx": "^GSPC", "spy": "SPY",
154	"nasdaq": "^IXIC", "nasdaq 100": "^NDX", "dow": "^DJI", "dow jones": "^DJI",
155	"russell 2000": "^RUT", "vix": "^VIX", "gold": "GC=F", "silver": "SI=F",
156	"oil": "CL=F", "crude": "CL=F", "natural gas": "NG=F", "10 year": "^TNX",
157	"bitcoin": "BTC-USD", "btc": "BTC-USD", "ethereum": "ETH-USD", "eth": "ETH-USD",
158	"solana": "SOL-USD", "dogecoin": "DOGE-USD",
159}
160
161// cnbcSym maps the tickers people write to the ones CNBC's quote cache uses.
162// CNBC is the primary here rather than Yahoo because Yahoo's spark and chart
163// endpoints both rate limit a home address hard, and this one does not.
164var cnbcSym = map[string]string{
165	"^GSPC": ".SPX", "^IXIC": ".IXIC", "^DJI": ".DJI", "^NDX": ".NDX", "^RUT": ".RUT",
166	"^VIX": ".VIX", "GC=F": "@GC.1", "SI=F": "@SI.1", "CL=F": "@CL.1", "NG=F": "@NG.1",
167	"^TNX": "US10Y",
168}
169
170var coinIDs = map[string]string{
171	"BTC-USD": "bitcoin", "ETH-USD": "ethereum", "SOL-USD": "solana",
172	"DOGE-USD": "dogecoin", "XRP-USD": "ripple",
173}
174
175type Quote struct {
176	Symbol    string  `json:"symbol"`
177	Name      string  `json:"name,omitempty"`
178	Price     float64 `json:"price"`
179	Change    string  `json:"change,omitempty"`
180	ChangePct string  `json:"change_pct,omitempty"`
181	PrevClose string  `json:"prev_close,omitempty"`
182	AsOf      string  `json:"as_of,omitempty"`
183	Currency  string  `json:"currency,omitempty"`
184	Source    string  `json:"source"`
185	Err       string  `json:"error,omitempty"`
186}
187
188var Markets = Tool{
189	Name: "markets",
190	Description: "Current price and daily change for stocks, indexes, commodities and crypto. " +
191		"Plain names work: \"S&P 500, gold, bitcoin\" as well as \"AAPL\".",
192	Schema: obj(map[string]any{
193		"symbols": str("comma separated symbols or plain names, up to eight"),
194	}, "symbols"),
195	Run: func(ctx context.Context, d *Deps, a map[string]any) (any, error) {
196		raw := argStr(a, "symbols")
197		if raw == "" {
198			return nil, fmt.Errorf("symbols is required")
199		}
200		var want []string
201		seen := map[string]bool{}
202		for _, s := range strings.FieldsFunc(raw, func(r rune) bool { return r == ',' || r == '|' || r == '\n' }) {
203			s = strings.TrimSpace(s)
204			if s == "" {
205				continue
206			}
207			sym := s
208			if v, ok := alias[strings.ToLower(s)]; ok {
209				sym = v
210			} else {
211				sym = strings.ToUpper(s)
212			}
213			if !seen[sym] {
214				seen[sym] = true
215				want = append(want, sym)
216			}
217			if len(want) >= 8 {
218				break
219			}
220		}
221		out := make([]Quote, 0, len(want))
222		var coins, rest []string
223		for _, s := range want {
224			if _, ok := coinIDs[s]; ok {
225				coins = append(coins, s)
226			} else {
227				rest = append(rest, s)
228			}
229		}
230		if len(coins) > 0 {
231			ids := make([]string, 0, len(coins))
232			for _, c := range coins {
233				ids = append(ids, coinIDs[c])
234			}
235			var cg map[string]struct {
236				USD    float64 `json:"usd"`
237				Change float64 `json:"usd_24h_change"`
238			}
239			u := "https://api.coingecko.com/api/v3/simple/price?vs_currencies=usd&include_24hr_change=true&ids=" + strings.Join(ids, ",")
240			if err := getJSON(ctx, d, u, &cg); err != nil {
241				for _, c := range coins {
242					out = append(out, Quote{Symbol: c, Source: "coingecko", Err: err.Error()})
243				}
244			} else {
245				for _, c := range coins {
246					v := cg[coinIDs[c]]
247					out = append(out, Quote{Symbol: c, Price: v.USD, Currency: "USD",
248						ChangePct: fmt.Sprintf("%+.2f%%", v.Change), Source: "coingecko"})
249				}
250			}
251		}
252		if len(rest) > 0 {
253			ids := make([]string, 0, len(rest))
254			for _, s := range rest {
255				if v, ok := cnbcSym[s]; ok {
256					ids = append(ids, v)
257				} else {
258					ids = append(ids, s)
259				}
260			}
261			var cn struct {
262				R struct {
263					Q json.RawMessage `json:"FormattedQuote"`
264				} `json:"FormattedQuoteResult"`
265			}
266			u := "https://quote.cnbc.com/quote-html-webservice/restQuote/symbolType/symbol?symbols=" +
267				url.QueryEscape(strings.Join(ids, "|")) +
268				"&requestMethod=itv&noform=1&partnerId=2&fund=1&exthrs=1&output=json&events=1"
269			if err := getJSON(ctx, d, u, &cn); err != nil {
270				for _, s := range rest {
271					out = append(out, Quote{Symbol: s, Source: "cnbc", Err: err.Error()})
272				}
273			} else {
274				type cq struct {
275					Symbol, ShortName, Last, Change, ChangePct, PreviousDayClosing, LastTimedate, CurrencyCode string
276				}
277				var rows []cq
278				// One symbol comes back as an object rather than an array.
279				if err := json.Unmarshal(cn.R.Q, &rows); err != nil {
280					var one cq
281					if json.Unmarshal(cn.R.Q, &one) == nil {
282						rows = []cq{one}
283					}
284				}
285				back := map[string]string{}
286				for k, v := range cnbcSym {
287					back[v] = k
288				}
289				got := map[string]Quote{}
290				for _, r := range rows {
291					sym := r.Symbol
292					if b, ok := back[sym]; ok {
293						sym = b
294					}
295					p, _ := strconv.ParseFloat(strings.ReplaceAll(r.Last, ",", ""), 64)
296					if p == 0 {
297						continue
298					}
299					got[sym] = Quote{Symbol: sym, Name: r.ShortName, Price: p, Change: r.Change,
300						ChangePct: r.ChangePct, PrevClose: r.PreviousDayClosing, AsOf: r.LastTimedate,
301						Currency: r.CurrencyCode, Source: "cnbc"}
302				}
303				for _, s := range rest {
304					if q, ok := got[s]; ok {
305						out = append(out, q)
306					} else {
307						out = append(out, Quote{Symbol: s, Source: "cnbc",
308							Err: "no quote for that symbol, try web_search"})
309					}
310				}
311			}
312		}
313		// A chart each for the first few that actually resolved. Charting a
314		// symbol whose quote came back empty draws an axis with nothing on it,
315		// which is what "energy,util" did: neither is a ticker, both were
316		// uppercased into one, and two empty panels went out above the answer.
317		charts := 0
318		for _, q := range out {
319			if charts >= 3 {
320				break
321			}
322			if q.Err != "" || q.Price == 0 {
323				continue
324			}
325			d.Widgets.Add(Widget{Kind: "ticker", Symbol: q.Symbol})
326			charts++
327		}
328		return map[string]any{"quotes": out}, nil
329	},
330}