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