▍ humdrum codex / sportsball v0.1.0
license AGPL-3.0
4.7 KB raw
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
package espn

import (
	"context"
	"encoding/json"
	"fmt"
	"net/http"
	"strings"

	"github.com/humdrum-tiv/sportsball/internal/model"
)

// Summary fetches the deeper per-game data (key events for soccer, box score
// for the stick-and-ball sports) from ESPN's summary endpoint and maps it into
// a league-agnostic model.GameDetail. This is the richer fetch behind the
// detail view, keyed by ESPN event ID.
func (c *Client) Summary(ctx context.Context, l model.League, eventID string) (model.GameDetail, error) {
	url := fmt.Sprintf("%s/%s/%s/summary?event=%s", baseURL, l.Sport, l.Path, eventID)
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
	if err != nil {
		return model.GameDetail{}, err
	}
	resp, err := c.HTTP.Do(req)
	if err != nil {
		return model.GameDetail{}, fmt.Errorf("summary %s: %w", eventID, err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		return model.GameDetail{}, fmt.Errorf("summary %s: status %d", eventID, resp.StatusCode)
	}

	var s summary
	if err := json.NewDecoder(resp.Body).Decode(&s); err != nil {
		return model.GameDetail{}, fmt.Errorf("decode summary %s: %w", eventID, err)
	}
	return mapSummary(s), nil
}

func mapSummary(s summary) model.GameDetail {
	d := model.GameDetail{}
	for _, e := range s.KeyEvents {
		d.Events = append(d.Events, mapKeyEvent(e))
	}
	// Scoring plays back the baseball timeline (soccer uses keyEvents above).
	// They're mutually exclusive per sport, so both feed model.Events.
	for _, p := range s.Plays {
		if p.ScoringPlay {
			d.Events = append(d.Events, mapScoringPlay(p))
		}
	}
	for _, tb := range s.Boxscore.Players {
		d.BoxScore = append(d.BoxScore, mapTeamBox(tb))
	}
	d.TeamStats = mapTeamStats(s.Boxscore.Teams)
	return d
}

// soccerTeamStatOrder is the curated subset of team stats shown as comparison
// bars, in display order. ESPN ships many more; these are the legible ones.
var soccerTeamStatOrder = []string{
	"possessionPct", "totalShots", "shotsOnTarget", "wonCorners", "foulsCommitted", "saves",
}

// mapTeamStats pairs the two teams' flat stat lists into away-vs-home rows for
// the curated stats present. Returns nil unless both home and away are found
// (the stick-and-ball sports nest team stats differently and yield nothing).
func mapTeamStats(teams []teamStatBox) []model.TeamStat {
	var home, away map[string]teamStatJSON
	for _, t := range teams {
		m := make(map[string]teamStatJSON, len(t.Statistics))
		for _, s := range t.Statistics {
			m[s.Name] = s
		}
		if t.HomeAway == "home" {
			home = m
		} else {
			away = m
		}
	}
	if home == nil || away == nil {
		return nil
	}
	var out []model.TeamStat
	for _, key := range soccerTeamStatOrder {
		h, okh := home[key]
		a, oka := away[key]
		if !okh && !oka {
			continue
		}
		out = append(out, model.TeamStat{
			Label: firstNonEmpty(h.Label, a.Label, key),
			Key:   key,
			Away:  a.DisplayValue,
			Home:  h.DisplayValue,
		})
	}
	return out
}

// mapScoringPlay converts a baseball scoring play into a MatchEvent. Clock is a
// compact half-inning marker (▲ top / ▼ bottom + inning); Team is matched by
// ID since the plays array only carries the team id.
func mapScoringPlay(p play) model.MatchEvent {
	mark := ""
	switch p.Period.Type {
	case "Top":
		mark = "▲"
	case "Bottom":
		mark = "▼"
	}
	clock := mark + fmt.Sprintf("%d", p.Period.Number)
	text := strings.TrimSpace(p.Text)
	return model.MatchEvent{
		Clock:     clock,
		Period:    p.Period.Number,
		Type:      strings.TrimSpace(p.AlternativeType.Text),
		Text:      text,
		ShortText: text,
		TeamID:    p.Team.ID,
		Scoring:   true,
	}
}

func mapKeyEvent(e keyEvent) model.MatchEvent {
	me := model.MatchEvent{
		Clock:     e.Clock.DisplayValue,
		Period:    e.Period.Number,
		Type:      strings.TrimSpace(e.Type.Text),
		Text:      strings.TrimSpace(e.Text),
		ShortText: strings.TrimSpace(e.ShortText),
		Team:      e.Team.DisplayName,
		TeamID:    e.Team.ID,
		Scoring:   e.ScoringPlay,
	}
	for _, p := range e.Participants {
		if n := p.Athlete.DisplayName; n != "" {
			me.Athletes = append(me.Athletes, n)
		}
	}
	return me
}

func mapTeamBox(tb playerBox) model.TeamBox {
	box := model.TeamBox{
		Abbr: tb.Team.Abbreviation,
		Name: tb.Team.DisplayName,
	}
	for _, st := range tb.Statistics {
		g := model.StatGroup{
			Name:   firstNonEmpty(st.Name, st.Type, st.DisplayName),
			Labels: st.Labels,
		}
		for _, a := range st.Athletes {
			name := a.Athlete.ShortName
			if name == "" {
				name = a.Athlete.DisplayName
			}
			g.Rows = append(g.Rows, model.PlayerRow{Athlete: name, Stats: a.Stats})
		}
		box.Groups = append(box.Groups, g)
	}
	return box
}

func firstNonEmpty(ss ...string) string {
	for _, s := range ss {
		if s != "" {
			return s
		}
	}
	return ""
}