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
|
package espn
import (
"context"
"encoding/json"
"fmt"
"net/http"
"sort"
"strconv"
"strings"
"github.com/humdrum-tiv/sportsball/internal/model"
)
// standingsBase is ESPN's standings host, which lives under apis/v2 (NOT the
// apis/site/v2 path the scoreboard/summary endpoints use).
const standingsBase = "https://site.api.espn.com/apis/v2/sports"
// colSpec maps a display column header to the ESPN stat `type` it pulls from.
type colSpec struct{ label, typ string }
// standingsCols is the per-sport column layout: which stats to show and in what
// order. Soccer is a points table; the stick-and-ball sports show records.
var standingsCols = map[string][]colSpec{
"soccer": {{"P", "gamesplayed"}, {"W", "wins"}, {"D", "ties"}, {"L", "losses"}, {"GD", "pointdifferential"}, {"Pts", "points"}},
"baseball": {{"W", "wins"}, {"L", "losses"}, {"PCT", "winpercent"}, {"GB", "gamesbehind"}},
"basketball": {{"W", "wins"}, {"L", "losses"}, {"PCT", "winpercent"}, {"GB", "gamesbehind"}},
"football": {{"W", "wins"}, {"L", "losses"}, {"T", "ties"}, {"PCT", "winpercent"}},
"hockey": {{"W", "wins"}, {"L", "losses"}, {"OTL", "otlosses"}, {"PTS", "points"}},
}
var defaultCols = []colSpec{{"W", "wins"}, {"L", "losses"}}
// rankStat is the ESPN stat each sport ranks its table by — ESPN returns the
// entries unsorted (or alphabetized), so we sort on this ourselves. Soccer
// ships an explicit "rank"; the stick-and-ball sports use "playoffseed".
var rankStat = map[string]string{
"soccer": "rank",
"baseball": "playoffseed",
"basketball": "playoffseed",
"hockey": "playoffseed",
"football": "playoffseed",
}
// Standings fetches and normalizes a league's standings table(s). Groups are
// flattened from ESPN's nested children (league/conference → entries) so each
// table that actually has teams becomes one model.StandingsGroup.
func (c *Client) Standings(ctx context.Context, l model.League) (model.Standings, error) {
url := fmt.Sprintf("%s/%s/%s/standings", standingsBase, l.Sport, l.Path)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return model.Standings{}, err
}
resp, err := c.HTTP.Do(req)
if err != nil {
return model.Standings{}, fmt.Errorf("standings %s: %w", l.ID, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return model.Standings{}, fmt.Errorf("standings %s: status %d", l.ID, resp.StatusCode)
}
var r standingsNode
if err := json.NewDecoder(resp.Body).Decode(&r); err != nil {
return model.Standings{}, fmt.Errorf("decode standings %s: %w", l.ID, err)
}
return mapStandings(l.ID, l.Sport, r), nil
}
func mapStandings(id model.LeagueID, sport string, root standingsNode) model.Standings {
cols := standingsCols[sport]
if cols == nil {
cols = defaultCols
}
labels := make([]string, len(cols))
for i, c := range cols {
labels[i] = c.label
}
rankKey := rankStat[sport]
if rankKey == "" {
rankKey = "playoffseed"
}
out := model.Standings{League: id}
// Walk the tree: a node with entries is a table; recurse into children.
var walk func(n standingsNode)
walk = func(n standingsNode) {
if len(n.Standings.Entries) > 0 {
out.Groups = append(out.Groups, mapGroup(n.Name, n.Standings.Entries, cols, labels, rankKey))
}
for _, ch := range n.Children {
walk(ch)
}
}
walk(root)
return out
}
func mapGroup(name string, entries []standingsEntry, cols []colSpec, labels []string, rankKey string) model.StandingsGroup {
// ESPN returns entries unsorted; order them by the sport's rank stat. Entries
// missing it sink to the bottom while keeping their relative order.
sort.SliceStable(entries, func(i, j int) bool {
return entryRank(entries[i], rankKey) < entryRank(entries[j], rankKey)
})
g := model.StandingsGroup{Name: name, Columns: labels}
for i, e := range entries {
byType := make(map[string]string, len(e.Stats))
for _, s := range e.Stats {
byType[s.Type] = s.DisplayValue
}
row := model.StandingsRow{Rank: i + 1, Team: teamFromJSON(e.Team)}
for _, c := range cols {
row.Values = append(row.Values, byType[c.typ])
}
g.Rows = append(g.Rows, row)
}
return g
}
// entryRank reads the rank stat as an int, returning a large sentinel when it's
// absent or unparseable so those entries sort last.
func entryRank(e standingsEntry, rankKey string) int {
for _, s := range e.Stats {
if s.Type == rankKey {
if n, err := strconv.Atoi(strings.TrimSpace(s.DisplayValue)); err == nil {
return n
}
}
}
return 1 << 30
}
// --- standings JSON ---------------------------------------------------------
// standingsNode is recursive: the root and each child share this shape — a name,
// an optional standings block (entries), and optional nested children.
type standingsNode struct {
Name string `json:"name"`
Standings standingsBlock `json:"standings"`
Children []standingsNode `json:"children"`
}
type standingsBlock struct {
Entries []standingsEntry `json:"entries"`
}
type standingsEntry struct {
Team teamJSON `json:"team"`
Stats []standingStat `json:"stats"`
}
type standingStat struct {
Type string `json:"type"`
DisplayValue string `json:"displayValue"`
}
|