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 "strings"
9)
10
11// leagues maps a name to ESPN's path. The host matters more than anything else
12// here: site.api.espn.com is what every write-up points at and it answers 403,
13// while cdn.espn.com is what espn.com itself calls and serves the same data to
14// anyone. Soccer is a query parameter rather than a path, and the wrong shape
15// answers 200 with no events, which looks like a quiet day rather than a bug.
16var leagues = map[string]string{
17 "nfl": "football/nfl", "college-football": "football/college-football",
18 "nba": "basketball/nba", "wnba": "basketball/wnba", "mlb": "baseball/mlb",
19 "nhl": "hockey/nhl", "tennis": "tennis", "golf": "golf",
20 "nascar": "racing/nascar-premier", "f1": "racing/f1",
21 "epl": "soccer?league=eng.1", "mls": "soccer?league=usa.1",
22 "champions-league": "soccer?league=uefa.champions",
23}
24
25var SportsScores = Tool{
26 Name: "sports_scores",
27 Description: "Live and recent scoreboard for one league. For anything not listed, use web_search instead.",
28 Schema: obj(map[string]any{
29 "league": map[string]any{"type": "string", "description": "which league",
30 "enum": []string{"nfl", "college-football", "nba", "wnba", "mlb", "nhl",
31 "tennis", "golf", "nascar", "f1", "epl", "mls", "champions-league"}},
32 }, "league"),
33 Run: func(ctx context.Context, d *Deps, a map[string]any) (any, error) {
34 key := strings.ToLower(argStr(a, "league"))
35 path, ok := leagues[key]
36 if !ok {
37 names := make([]string, 0, len(leagues))
38 for k := range leagues {
39 names = append(names, k)
40 }
41 return nil, fmt.Errorf("no league called %q, known: %s", key, strings.Join(names, ", "))
42 }
43 sep := "?"
44 if strings.Contains(path, "?") {
45 sep = "&"
46 }
47 var raw struct {
48 Content struct {
49 SBData struct {
50 Events json.RawMessage `json:"events"`
51 } `json:"sbData"`
52 Events json.RawMessage `json:"events"`
53 } `json:"content"`
54 }
55 if err := getJSON(ctx, d, "https://cdn.espn.com/core/"+path+sep+"xhr=1", &raw); err != nil {
56 return nil, fmt.Errorf("%w (try web_search for the scores)", err)
57 }
58 blob := raw.Content.SBData.Events
59 if len(blob) == 0 {
60 blob = raw.Content.Events
61 }
62 var evs []struct {
63 Name string `json:"name"`
64 ShortName string `json:"shortName"`
65 Date string `json:"date"`
66 Status struct {
67 Type struct {
68 Detail string `json:"detail"`
69 Completed bool `json:"completed"`
70 } `json:"type"`
71 } `json:"status"`
72 Competitions []struct {
73 Competitors []struct {
74 Team struct {
75 DisplayName string `json:"displayName"`
76 } `json:"team"`
77 Score string `json:"score"`
78 Athlete struct {
79 DisplayName string `json:"displayName"`
80 } `json:"athlete"`
81 } `json:"competitors"`
82 } `json:"competitions"`
83 }
84 if len(blob) > 0 {
85 _ = json.Unmarshal(blob, &evs)
86 }
87 type side struct {
88 Name string `json:"name"`
89 Score string `json:"score,omitempty"`
90 }
91 type game struct {
92 Name string `json:"name"`
93 Date string `json:"date"`
94 Status string `json:"status"`
95 Completed bool `json:"completed"`
96 Sides []side `json:"sides,omitempty"`
97 }
98 out := make([]game, 0, 16)
99 for _, e := range evs {
100 g := game{Name: firstNonEmpty(e.ShortName, e.Name), Date: e.Date,
101 Status: e.Status.Type.Detail, Completed: e.Status.Type.Completed}
102 if len(e.Competitions) > 0 {
103 for _, c := range e.Competitions[0].Competitors {
104 n := firstNonEmpty(c.Team.DisplayName, c.Athlete.DisplayName)
105 if n != "" {
106 g.Sides = append(g.Sides, side{Name: n, Score: c.Score})
107 }
108 }
109 }
110 out = append(out, g)
111 if len(out) >= 16 {
112 break
113 }
114 }
115 if len(out) == 0 {
116 return map[string]any{"league": key, "events": out,
117 "note": "no events on the board for this league right now"}, nil
118 }
119 return map[string]any{"league": key, "events": out}, nil
120 },
121}
122
123func firstNonEmpty(s ...string) string {
124 for _, v := range s {
125 if strings.TrimSpace(v) != "" {
126 return v
127 }
128 }
129 return ""
130}
131
132// ---------------------------------------------------------------- odds
133
134var Odds = Tool{
135 Name: "odds",
136 Description: "What a real betting market implies about an event, as a percentage. " +
137 "This is a price people are paying, not a forecast, and it should be said that way. " +
138 "Only for things people bet on: an election, a match, a nomination, a rate decision. " +
139 "It is not a price check and knows nothing about what a product costs, so use " +
140 "web_search for anything on sale.",
141 Schema: obj(map[string]any{
142 "query": str("the event, like \"US Open winner\" or \"government shutdown\""),
143 }, "query"),
144 Run: func(ctx context.Context, d *Deps, a map[string]any) (any, error) {
145 q := argStr(a, "query")
146 if q == "" {
147 return nil, fmt.Errorf("query is required")
148 }
149 // /events?search= is silently ignored by Polymarket and hands back the
150 // top volume list whatever you ask, which is how a question about
151 // tennis came back with a presidential nomination market.
152 // /public-search is the endpoint that actually searches.
153 var res struct {
154 Events []struct {
155 Title string `json:"title"`
156 Volume any `json:"volume"`
157 Markets []struct {
158 Question string `json:"question"`
159 Outcomes string `json:"outcomes"`
160 Prices string `json:"outcomePrices"`
161 VolumeNum any `json:"volumeNum"`
162 } `json:"markets"`
163 } `json:"events"`
164 }
165 u := "https://gamma-api.polymarket.com/public-search?limit_per_type=10&events_status=active&q=" + url.QueryEscape(q)
166 if err := getJSON(ctx, d, u, &res); err != nil {
167 return nil, fmt.Errorf("%w (try web_search)", err)
168 }
169 type leg struct {
170 Outcome string `json:"outcome"`
171 Pct float64 `json:"implied_pct"`
172 }
173 type mkt struct {
174 Event string `json:"event"`
175 Market string `json:"market"`
176 Volume float64 `json:"volume_usd"`
177 Legs []leg `json:"legs"`
178 }
179 var out []mkt
180 for _, e := range res.Events {
181 for _, m := range e.Markets {
182 // Polymarket encodes these arrays as JSON strings inside its
183 // JSON, so reading them as []string silently yields nothing.
184 var names []string
185 var prices []string
186 if json.Unmarshal([]byte(m.Outcomes), &names) != nil {
187 continue
188 }
189 if json.Unmarshal([]byte(m.Prices), &prices) != nil {
190 continue
191 }
192 vol := asFloat(m.VolumeNum)
193 if vol == 0 {
194 vol = asFloat(e.Volume)
195 }
196 // A novelty market with two hundred dollars in it is noise next
197 // to one with twenty million, and answering from the first is
198 // how "how is the US Open going" got a Chipotle market.
199 if vol < 10000 {
200 continue
201 }
202 var legs []leg
203 for i := range names {
204 if i >= len(prices) {
205 break
206 }
207 var p float64
208 fmt.Sscanf(prices[i], "%g", &p)
209 // Exactly 0 or 1 is a settled leg, an eliminated name
210 // rather than a long shot, so it is dropped instead of
211 // being listed at 0%.
212 if p > 0 && p < 1 {
213 legs = append(legs, leg{Outcome: names[i], Pct: round1(p * 100)})
214 }
215 }
216 if len(legs) > 0 {
217 out = append(out, mkt{Event: e.Title, Market: m.Question, Volume: vol, Legs: legs})
218 }
219 if len(out) >= 8 {
220 break
221 }
222 }
223 if len(out) >= 8 {
224 break
225 }
226 }
227 if len(out) == 0 {
228 return nil, fmt.Errorf("no active betting market matching %q, try web_search", q)
229 }
230 return map[string]any{"markets": out,
231 "note": "implied probability from a betting market, which is a price and not a forecast"}, nil
232 },
233}
234
235func asFloat(v any) float64 {
236 switch n := v.(type) {
237 case float64:
238 return n
239 case string:
240 var f float64
241 fmt.Sscanf(n, "%g", &f)
242 return f
243 }
244 return 0
245}
246
247func round1(f float64) float64 { return float64(int(f*10+0.5)) / 10 }