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

14.3 KB · 478 lines · Go Raw History
  1package skills
  2
  3import (
  4	"context"
  5	"errors"
  6	"fmt"
  7	"strings"
  8	"sync"
  9	"time"
 10)
 11
 12// "Did the Panthers win last night" has an exact answer, and reading it off a
 13// scoreboard beats paraphrasing a recap someone wrote about it.
 14//
 15// The source is ESPN's own site data. Note the host: site.api.espn.com is the
 16// documented-by-folklore one and it answers 403 to anything off ESPN's network,
 17// while cdn.espn.com, which is what espn.com itself calls, serves the same
 18// scoreboard to anyone. That difference is the whole reason this works.
 19//
 20// No team list is hardcoded. Team names come out of the scoreboard responses,
 21// so a franchise moving or renaming needs no change here.
 22
 23// Two URL shapes, which is not obvious and cost a round of debugging: the
 24// American leagues are a path (core/nfl/scoreboard) while soccer is one path
 25// with the competition as a parameter (core/soccer/scoreboard?league=eng.1).
 26// Asking for core/soccer/eng.1/scoreboard answers 200 with a payload that has
 27// no events in it at all, so it looks like a quiet day rather than a wrong URL.
 28const (
 29	espnLeagueURL = "https://cdn.espn.com/core/%s/scoreboard?xhr=1"
 30	espnSoccerURL = "https://cdn.espn.com/core/soccer/scoreboard?xhr=1&league=%s"
 31)
 32
 33// Every league is checked in parallel, because a bare team name is often
 34// ambiguous. "Panthers" is Carolina in the NFL and Florida in the NHL, and
 35// answering for one silently would be wrong half the time.
 36//
 37// The soccer list is long on purpose. It is the sport most likely to come up in
 38// conversation with people outside the US, and a European league missing from
 39// this list is a question that falls through to a web search.
 40type league struct {
 41	path   string
 42	name   string
 43	soccer bool
 44}
 45
 46var leagues = []league{
 47	{"nfl", "NFL", false},
 48	{"college-football", "college football", false},
 49	{"nba", "NBA", false},
 50	{"mlb", "MLB", false},
 51	{"nhl", "NHL", false},
 52
 53	{"eng.1", "Premier League", true},
 54	{"esp.1", "La Liga", true},
 55	{"ita.1", "Serie A", true},
 56	{"ger.1", "Bundesliga", true},
 57	{"fra.1", "Ligue 1", true},
 58	{"por.1", "Primeira Liga", true},
 59	{"ned.1", "Eredivisie", true},
 60	{"usa.1", "MLS", true},
 61	{"eng.2", "Championship", true},
 62	{"uefa.champions", "Champions League", true},
 63	{"uefa.europa", "Europa League", true},
 64	{"fifa.world", "World Cup", true},
 65}
 66
 67type espnPayload struct {
 68	Content struct {
 69		SBData struct {
 70			Events []espnEvent `json:"events"`
 71		} `json:"sbData"`
 72	} `json:"content"`
 73}
 74
 75type espnEvent struct {
 76	Name   string `json:"name"`
 77	Date   string `json:"date"`
 78	Status struct {
 79		Type struct {
 80			Completed   bool   `json:"completed"`
 81			ShortDetail string `json:"shortDetail"`
 82			State       string `json:"state"`
 83		} `json:"type"`
 84	} `json:"status"`
 85	Competitions []struct {
 86		Competitors []struct {
 87			HomeAway string `json:"homeAway"`
 88			Score    string `json:"score"`
 89			Winner   bool   `json:"winner"`
 90			Team     struct {
 91				DisplayName string `json:"displayName"`
 92				Name        string `json:"name"`
 93				Location    string `json:"location"`
 94				Abbrev      string `json:"abbreviation"`
 95			} `json:"team"`
 96		} `json:"competitors"`
 97	} `json:"competitions"`
 98	Links []struct {
 99		Href string `json:"href"`
100	} `json:"links"`
101}
102
103// gameResult is one match involving the team asked about.
104type gameResult struct {
105	League    string
106	Date      time.Time
107	Completed bool
108	Detail    string
109	Home      string
110	Away      string
111	HomeScore string
112	AwayScore string
113	Winner    string
114	Link      string
115}
116
117// Sports reads a scoreboard. It answers what already happened, so a question
118// about a fixture that has not been played yet has no result to give and falls
119// through to the web.
120type Sports struct{}
121
122func (Sports) Card() Card {
123	return Card{
124		Name: "sports",
125		Does: "reports the score and result of a recent match for a named team, from the scoreboard.",
126		Fires: []string{
127			"did Liverpool win their last match",
128			"what was the score in the chiefs game",
129			"who won the arsenal match",
130			"did the yankees win last night",
131			"final score of the lakers game",
132		},
133		NotFor: []string{
134			"when do liverpool play next",
135			"who is the best premier league team",
136			"how many super bowls have the patriots won",
137			"what are the odds on the us open",
138			"explain the offside rule",
139		},
140		Keywords: []string{"did the ", "who won", "final score", "score in the",
141			"win last night", "won last night", "match result", "did they win"},
142	}
143}
144
145func looksLikeSport(q string) bool {
146	l := strings.ToLower(q)
147	return containsAny(l,
148		"did the ", "who won", "score", "did we win", "final score",
149		"beat the ", "play last night", "playing tonight", "game last night",
150		"win last night", "won last night", "won yesterday", "win yesterday",
151		// Football in the rest of the world, and the words around a match.
152		"match result", "did they win", "premier league", "la liga", "serie a",
153		"bundesliga", "champions league", "europa league", "fixture", "kick off",
154		"how did ", " draw ", "nil nil", "full time")
155}
156
157func (Sports) Run(ctx context.Context, question string, d Deps) (*Result, error) {
158	start := d.now()
159
160	// A date-oriented league needs to be asked for the right day. Week-oriented
161	// ones (the NFL) ignore it and return the current week, which is what a
162	// question about last night wants anyway.
163	when := targetDate(question, d)
164
165	var (
166		mu        sync.Mutex
167		all       []gameResult
168		throttled bool
169		wg        sync.WaitGroup
170		team      = teamWords(question)
171	)
172	if len(team) == 0 {
173		return nil, nil
174	}
175
176	// One question used to fetch every league at once, seventeen requests of
177	// about a megabyte, and ESPN answered the lot with 202 and an empty body.
178	// It reads as an empty scoreboard rather than as a refusal, so the skill
179	// looked like it simply had no game to report.
180	//
181	// So the sweep is ordered and stops early. A question naming its
182	// competition goes straight to it, and otherwise the leagues are tried a
183	// few at a time, most likely first, until one has the team in it.
184	order := leagueOrder(question)
185	for i := 0; i < len(order); i += 3 {
186		wave := order[i:min(i+3, len(order))]
187		for _, lg := range wave {
188			wg.Add(1)
189			go func(path, name string, soccer bool) {
190				defer wg.Done()
191
192				url := fmt.Sprintf(espnLeagueURL, path)
193				if soccer {
194					url = fmt.Sprintf(espnSoccerURL, path)
195				}
196				if !when.IsZero() {
197					url += "&dates=" + when.Format("20060102")
198				}
199				var p espnPayload
200				if err := getJSONCached(ctx, d, "espn.com", url, &p); err != nil {
201					if errors.Is(err, errThrottled) {
202						mu.Lock()
203						throttled = true
204						mu.Unlock()
205					}
206					return
207				}
208				for _, ev := range p.Content.SBData.Events {
209					if g, ok := matchTeam(ev, team, name); ok {
210						mu.Lock()
211						all = append(all, g)
212						mu.Unlock()
213					}
214				}
215			}(lg.path, lg.name, lg.soccer)
216		}
217		wg.Wait()
218		// A team in two leagues is nearly always in two of the same wave, so
219		// stopping here still catches the case the answer mentions.
220		mu.Lock()
221		done := len(all) > 0
222		mu.Unlock()
223		if done {
224			break
225		}
226	}
227	// A throttled upstream and a team with no fixture both come back empty, and
228	// telling them apart matters: the first is worth retrying and worth seeing
229	// in a log, and the second is a real answer the web can give instead.
230	if len(all) == 0 {
231		if throttled {
232			return nil, errThrottled
233		}
234		return nil, nil
235	}
236	// Most recent first, and a finished game beats a scheduled one, because
237	// "did they win" is a question about something that already happened.
238	sortGames(all)
239
240	var b strings.Builder
241	head := all[0]
242	// Asking about last night and being shown a fixture is an answer to a
243	// different question, so say which one it is.
244	if asksAboutPast(question) && !head.Completed {
245		fmt.Fprintf(&b, "**No game has been played yet.** %s\n\n",
246			strings.TrimPrefix(headline(head, d), "Not played yet. "))
247	} else {
248		b.WriteString(headline(head, d) + "\n\n")
249	}
250	for _, g := range all {
251		line := fmt.Sprintf("- **%s %s, %s %s** (%s), %s, %s",
252			g.Away, g.AwayScore, g.Home, g.HomeScore, g.League,
253			g.Detail, g.Date.In(d.now().Location()).Format("Mon 2 Jan"))
254		if !g.Completed {
255			line = fmt.Sprintf("- **%s at %s** (%s), %s", g.Away, g.Home, g.League, g.Detail)
256		}
257		b.WriteString(line + "\n")
258	}
259	if len(distinctLeagues(all)) > 1 {
260		b.WriteString("\nThat name belongs to a team in more than one league, so every match is listed.\n")
261	}
262	fmt.Fprintf(&b, "\nFrom ESPN, read %s.", d.now().Format("3:04 PM on 2 January"))
263
264	text := b.String()
265	var sources []Source
266	for i, g := range all {
267		if g.Link == "" || i >= 3 {
268			continue
269		}
270		sources = append(sources, Source{
271			URL:   g.Link,
272			Title: fmt.Sprintf("%s at %s", g.Away, g.Home), Site: "espn.com",
273		})
274	}
275
276	return &Result{
277		Skill: "sports", Shape: "news", Text: text, Sources: sources,
278		Elapsed: d.now().Sub(start).Round(10 * time.Millisecond).String(),
279	}, nil
280}
281
282// targetDate reads the day a question is about. Zero means today, which is what
283// the scoreboard returns without a date.
284func targetDate(q string, d Deps) time.Time {
285	l := strings.ToLower(q)
286	now := d.now()
287	switch {
288	case containsAny(l, "last night", "yesterday"):
289		return now.AddDate(0, 0, -1)
290	case containsAny(l, "tonight", "today"):
291		return now
292	case containsAny(l, "this weekend", "saturday"):
293		return now
294	}
295	return time.Time{}
296}
297
298// teamWords pulls the capitalised words that could be a team name. Common
299// question words are dropped so "did the Panthers win" leaves "panthers".
300func teamWords(q string) []string {
301	skip := map[string]bool{
302		"did": true, "the": true, "win": true, "won": true, "lose": true, "lost": true,
303		"last": true, "night": true, "yesterday": true, "today": true, "tonight": true,
304		"who": true, "what": true, "was": true, "score": true, "of": true, "game": true,
305		"play": true, "playing": true, "beat": true, "against": true, "vs": true,
306		"final": true, "and": true, "for": true, "their": true, "this": true, "weekend": true,
307		"how": true, "many": true, "points": true, "did the": true, "we": true, "our": true,
308	}
309	var out []string
310	for _, f := range strings.Fields(strings.ToLower(q)) {
311		w := strings.Trim(f, ".,?!'\"")
312		if len(w) < 3 || skip[w] {
313			continue
314		}
315		out = append(out, w)
316	}
317	return out
318}
319
320// matchTeam looks for the asked-about team in an event. Matching is on the
321// nickname, the city, or the abbreviation, so "panthers", "carolina" and "car"
322// all find the same game.
323func matchTeam(ev espnEvent, words []string, league string) (gameResult, bool) {
324	if len(ev.Competitions) == 0 || len(ev.Competitions[0].Competitors) < 2 {
325		return gameResult{}, false
326	}
327	hit := false
328	for _, c := range ev.Competitions[0].Competitors {
329		fields := []string{
330			strings.ToLower(c.Team.Name),
331			strings.ToLower(c.Team.Location),
332			strings.ToLower(c.Team.Abbrev),
333			strings.ToLower(c.Team.DisplayName),
334		}
335		for _, w := range words {
336			for _, f := range fields {
337				if f == w || (len(w) > 4 && strings.Contains(f, w)) {
338					hit = true
339				}
340			}
341		}
342	}
343	if !hit {
344		return gameResult{}, false
345	}
346
347	g := gameResult{
348		League:    league,
349		Completed: ev.Status.Type.Completed,
350		Detail:    ev.Status.Type.ShortDetail,
351	}
352	if t, err := time.Parse("2006-01-02T15:04Z", ev.Date); err == nil {
353		g.Date = t
354	} else if t, err := time.Parse(time.RFC3339, ev.Date); err == nil {
355		g.Date = t
356	}
357	for _, c := range ev.Competitions[0].Competitors {
358		if c.HomeAway == "home" {
359			g.Home, g.HomeScore = c.Team.DisplayName, c.Score
360		} else {
361			g.Away, g.AwayScore = c.Team.DisplayName, c.Score
362		}
363		if c.Winner {
364			g.Winner = c.Team.DisplayName
365		}
366	}
367	if len(ev.Links) > 0 {
368		g.Link = ev.Links[0].Href
369	}
370	return g, true
371}
372
373// headline is the sentence that answers the question. A draw is a real result
374// and not a missing winner, which is what an empty Winner field means in
375// football and what printed a row of asterisks before.
376func asksAboutPast(q string) bool {
377	l := strings.ToLower(q)
378	return containsAny(l, "last night", "yesterday", "did the", "did they",
379		"who won", "final score", "was the score", "did we")
380}
381
382func headline(g gameResult, d Deps) string {
383	when := g.Date.In(d.now().Location()).Format("Monday 2 January")
384
385	if !g.Completed {
386		if g.Detail != "" {
387			return fmt.Sprintf("Not played yet. **%s at %s**, %s.", g.Away, g.Home, g.Detail)
388		}
389		return fmt.Sprintf("Not played yet. **%s at %s**.", g.Away, g.Home)
390	}
391
392	if g.Winner == "" {
393		return fmt.Sprintf("**Drew %s to %s.** %s and %s, on %s.",
394			g.HomeScore, g.AwayScore, g.Home, g.Away, when)
395	}
396
397	loser, winScore, loseScore := g.Away, g.HomeScore, g.AwayScore
398	if g.Winner == g.Away {
399		loser, winScore, loseScore = g.Home, g.AwayScore, g.HomeScore
400	}
401	return fmt.Sprintf("**%s beat %s, %s to %s**, on %s.",
402		g.Winner, loser, winScore, loseScore, when)
403}
404
405func sortGames(g []gameResult) {
406	for i := 1; i < len(g); i++ {
407		for j := i; j > 0 && better(g[j], g[j-1]); j-- {
408			g[j], g[j-1] = g[j-1], g[j]
409		}
410	}
411}
412
413// better ranks a finished game above a scheduled one, then by recency.
414func better(a, b gameResult) bool {
415	if a.Completed != b.Completed {
416		return a.Completed
417	}
418	return a.Date.After(b.Date)
419}
420
421func distinctLeagues(g []gameResult) []string {
422	seen := map[string]bool{}
423	var out []string
424	for _, x := range g {
425		if !seen[x.League] {
426			seen[x.League] = true
427			out = append(out, x.League)
428		}
429	}
430	return out
431}
432
433// leagueOrder puts the league a question names first, and otherwise sorts by
434// how often a team gets asked about. The sweep stops at the first wave with a
435// hit, so the order is what decides how many requests a question costs.
436func leagueOrder(question string) []league {
437	l := strings.ToLower(question)
438	named := map[string]string{
439		"premier league": "eng.1", "epl": "eng.1",
440		"la liga": "esp.1", "serie a": "ita.1", "bundesliga": "ger.1",
441		"ligue 1": "fra.1", "eredivisie": "ned.1", "primeira": "por.1",
442		"championship": "eng.2", "mls": "usa.1",
443		"champions league": "uefa.champions", "europa league": "uefa.europa",
444		"world cup": "fifa.world",
445		"nfl":       "nfl", "nba": "nba", "mlb": "mlb", "nhl": "nhl",
446		"college football": "college-football",
447	}
448	var first string
449	for word, path := range named {
450		if strings.Contains(l, word) && len(word) > len(firstWord(named, first)) {
451			first = path
452		}
453	}
454	out := make([]league, 0, len(leagues))
455	if first != "" {
456		for _, lg := range leagues {
457			if lg.path == first {
458				out = append(out, lg)
459			}
460		}
461	}
462	for _, lg := range leagues {
463		if lg.path != first {
464			out = append(out, lg)
465		}
466	}
467	return out
468}
469
470func firstWord(m map[string]string, path string) string {
471	for w, p := range m {
472		if p == path {
473			return w
474		}
475	}
476	return ""
477}