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

7.6 KB · 248 lines · Go Raw History
  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 }