orchard
mirrorEvery 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
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}