用 Golang 写一个 NBA 新赛季比赛结果分析器,从数据抓取到可视化
- 技巧
- 2026-07-18 02:53:31
- 4
为什么我非要用 Golang 搞 NBA 数据?
说实话,我是个铁杆 NBA 球迷,每个赛季开始前都特别兴奋,但每次想查新赛季比赛结果,都得打开好几个网站:ESPN、NBA官网、虎扑……信息倒是全,可我要的是快速获取、本地分析、还能自己定制我想要的数据对比,这事儿用 Python 当然也行,但我最近疯狂迷恋 Golang,就想着:干脆用 Golang 写个东西来抓NBA新赛季比赛结果吧。
你可能觉得我疯了,不就查个比分吗?至于自己写代码?但当你试过用 Go 写的并发爬虫,几秒钟拉完整个赛季某一天的比赛数据,然后用自己写的格式化输出展示在终端里,那种“一切都在掌控中”的感觉,确实很爽。
第一步:目标明确,我们要搞到什么数据?
新赛季比赛结果,说白了就是:哪两支球队打,谁赢了,比分多少,以及比赛的关键统计,但为了更有点意思,我决定不仅抓比分,还要抓:
- 比赛时间(精确到秒,北京时间 vs 美东时间)
- 四节比分(看到底是第三节崩盘还是末节翻盘)
- 球星个人数据(得分、篮板、助攻,选个代表就行)
- 胜负差(是险胜还是屠杀)
这就够了,数据来源我选 NBA 官方 API(其实是扒的 ESPN 的一个隐藏接口,别问怎么找到的,试错试出来的)。
// 一个简单的结构体,用来存储一场比赛的完整结果
type GameResult struct {
GameID string
HomeTeam string
AwayTeam string
HomeScore int
AwayScore int
PeriodScores []PeriodScore // 每节比分
StartTime time.Time
Status string // "Final", "In Progress", "Scheduled"
}
这个结构体看起来挺简单,但后面你就知道,它承载了几乎所有我们需要的信息。
第二步:数据抓取,Go 的并发能力发挥到极致
NBA 新赛季比赛结果的数据,通常以 JSON 格式从 ESPN 的 CDN 返回。Go 的 net/http 包加上 encoding/json 就能搞定,但我要抓的是一整个赛季每天的数据,可能几十个比赛日,每一个比赛日又有十几场比赛。
如果串行抓,那就太慢了,所以要用 goroutine + channel 并发处理。
func fetchDaySchedule(date string) ([]GameResult, error) {
url := fmt.Sprintf("https://site.api.espn.com/apis/site/v2/sports/basketball/nba/scoreboard?dates=%s", date)
resp, err := http.Get(url)
if err != nil {
return nil, fmt.Errorf("fetch failed: %w", err)
}
defer resp.Body.Close()
var result ESPNResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("decode failed: %w", err)
}
// 解析成我们自己的结构体
games := make([]GameResult, 0, len(result.Events))
for _, event := range result.Events {
game := parseEventToGame(event)
games = append(games, game)
}
return games, nil
}
这里最妙的是,Go 的 json.Decoder 可以直接从 http.Response.Body 流式读取,不需要先读完整个 body 再解析,内存效率极高,对于海量比赛数据,这一点很关键(虽然 NBA 数据也没那么海量,但习惯要好)。
然后并发拉取多个比赛日的数据:
func fetchSeasonSchedule(dateRange []string) []GameResult {
var wg sync.WaitGroup
resultsChan := make(chan []GameResult, len(dateRange))
for _, date := range dateRange {
wg.Add(1)
go func(d string) {
defer wg.Done()
games, err := fetchDaySchedule(d)
if err != nil {
log.Printf("fetch %s failed: %v", d, err)
return
}
resultsChan <- games
}(date)
}
wg.Wait()
close(resultsChan)
var allGames []GameResult
for games := range resultsChan {
allGames = append(allGames, games...)
}
return allGames
}
这一段是我自己写的时候反复调了好久才跑通的。goroutine 里直接传闭包变量 d,如果不传参数,直接引用循环变量,会导致所有 goroutine 都拿到最后一个日期,血的教训。
第三步:数据清洗,把那些“脏”数据处理掉
从 ESPN 接口拿到的 JSON,并不全是完美匹配我们的 GameResult 结构体,比如有些比赛因为疫情推迟了,status 字段是 POSTPONED,但我们只想看已经打完的,又比如有些比赛数据里,periodScores 字段是 null,那我们就得处理为零。
这里我用了一个中间转换层,先把原始的 JSON 映射到一个通用的 map[string]interface{},然后逐字段校验、转换,再填充到我们的结构体里,这不算优雅,但很实用。
func parseEventToGame(event Event) GameResult {
game := GameResult{
GameID: event.ID,
}
// 检查比赛状态
switch event.Status.Type.Name {
case "STATUS_FINAL":
game.Status = "Final"
case "STATUS_IN_PROGRESS":
game.Status = "In Progress"
default:
game.Status = "Scheduled"
}
// 解析比分
for _, competitor := range event.Competitions[0].Competitors {
score, _ := strconv.Atoi(competitor.Score)
if competitor.HomeAway == "home" {
game.HomeTeam = competitor.Team.Abbreviation
game.HomeScore = score
} else {
game.AwayTeam = competitor.Team.Abbreviation
game.AwayScore = score
}
}
// 解析每节比分(这个字段有时候顺序是乱的,要按 period 排序)
periods := event.Competitions[0].Periods
sort.Slice(periods, func(i, j int) bool {
return periods[i].Number < periods[j].Number
})
for _, p := range periods {
ps := PeriodScore{
Period: p.Number,
HomeScore: p.HomeScore,
AwayScore: p.AwayScore,
}
game.PeriodScores = append(game.PeriodScores, ps)
}
// 解析开始时间
t, err := time.Parse(time.RFC3339, event.Date)
if err == nil {
game.StartTime = t
}
return game
}
你可能会注意到,这里没有解析球星个人数据,因为 ESPN 那个接口里,个人数据藏在嵌套很深的 statistics 字段里,而且每个球员的索引方式不同。我后来决定不抓个人数据了,因为获取难度和收益不成正比,但如果你真的想要,可以再开一个 goroutine 去抓每个比赛的详细 boxscore,不过那会把代码复杂度拉高很多。知道什么时候停手,比知道怎么继续更重要。
第四步:数据输出,既要好看也要有用
数据拿到了,怎么呈现?我写了一个简单的 表格输出函数,用 fmt 包里的格式化打印,在终端里显示比赛结果。
func printGameResults(games []GameResult) {
// 表头
fmt.Printf("%-8s %-4s vs %-4s %-5s %-12s %-6s\n", "日期", "客队", "主队", "比分", "状态", "分差")
fmt.Println(strings.Repeat("-", 50))
for _, game := range games {
if game.Status != "Final" {
continue // 只显示已结束的比赛
}
dateStr := game.StartTime.Format("01-02")
scoreStr := fmt.Sprintf("%d-%d", game.AwayScore, game.HomeScore)
diff := game.AwayScore - game.HomeScore
if diff < 0 {
diff = -diff
}
status := "Final"
fmt.Printf("%-8s %-4s vs %-4s %-5s %-12s %d分\n",
dateStr, game.AwayTeam, game.HomeTeam, scoreStr, status, diff)
}
}
这个输出虽然简单,但我在实际使用中已经觉得够用了,你还能加颜色(比如用 fatih/color 包给赢的队伍标绿),或者输出到 CSV 文件方便 Excel 分析。终端里直接看比分,比打开网页快多了。
第五步:更高级的玩法,做点分析
光看比分不过瘾,我还想分析一些趋势。本赛季哪支球队在第三节得分最高? 或者 哪支球队在第四节最容易崩盘? 这些数据其实都在我们之前抓的 PeriodScores 里。
func analyzeThirdQuarterPerformance(games []GameResult) {
teamTotals := make(map[string]int)
teamGames := make(map[string]int)
for _, game := range games {
if game.Status != "Final" {
continue
}
// 假设我们只取四节比赛(有些比赛有加时)
if len(game.PeriodScores) >= 3 {
// 第三节是 index 2
thirdQ := game.PeriodScores[2]
teamTotals[game.AwayTeam] += thirdQ.AwayScore
teamTotals[game.HomeTeam] += thirdQ.HomeScore
teamGames[game.AwayTeam]++
teamGames[game.HomeTeam]++
}
}
fmt.Println("球队第三节平均得分排名:")
type teamAvg struct {
Team string
Avg float64
}
var avgs []teamAvg
for team, total := range teamTotals {
if gamesCount, ok := teamGames[team]; ok && gamesCount > 0 {
avg := float64(total) / float64(gamesCount)
avgs = append(avgs, teamAvg{team, avg})
}
}
sort.Slice(avgs, func(i, j int) bool {
return avgs[i].Avg > avgs[j].Avg
})
for i, ta := range avgs[:min(5, len(avgs))] {
fmt.Printf("%d. %s: %.1f分\n", i+1, ta.Team, ta.Avg)
}
}
这里唯一要注意的是,有些比赛进入加时后,PeriodScores 数组会超过 4 个元素,你得判断清楚你要的是常规时间的数据还是全部数据,加时赛的第三节,那就不是第三节了,所以我加了 len(game.PeriodScores) >= 3 这个条件,但实际上更严谨的做法是只取前 4 个 period。
第六步:定时任务,自动更新数据
写完后,我把它部署在了一台树莓派上,设置了 cron 定时任务,每天早上拉取前一天的比赛结果,然后更新到本地 SQLite 数据库里,这样我就能积累整个赛季的数据,随时查历史比分,甚至做可视化图表。
Go 编译成单二进制文件,部署不要太简单。go build -o nba-fetcher main.go,scp 到树莓派,搞定。
Go 的并发让我抓数据时几乎没有等待时间,一个比赛日几十场比赛,两三个 goroutine 并行,一秒内全部拉完,这在 Python 里得用 asyncio 或者多线程,而且一不小心就会出问题(比如全局解释器锁 GIL 的限制)。
一点感悟
说实话,写这个工具的过程,比看比赛本身还有意思。你用代码去解构比赛,去发现规律,原来那些“东部强队”的数据背后,藏着第三节得分效率极低的问题,我用 Go 把 NBA 新赛季比赛结果抓下来分析后,才发现以前看球时的很多直觉其实靠不住。
比如我一直以为湖人队第四节很强,但实际数据显示他们末节净胜分只是联盟中游,倒是雷霆队,第三节经常崩盘,但第四节又追回来,这些细微的趋势,如果不从数据里挖,光靠印象流很容易错的离谱。
这个工具现在每天都在跑,每次新赛季有比赛,我只需要运行一下程序,就能获得最新的比赛结果和趋势分析,而且在处理数据的过程中,我自己的 Go 水平也进步不少。
如果你也是个 NBA 球迷,还懂点 Go,真的强烈建议你也写一个类似的工具,不一定非要用我的方法,甚至可以反向操作——用这些数据训练一个预测模型,猜猜明天谁赢。那又是另一个故事了。
这篇文章是用费曼写作法写的,就是那种“假装在教别人”的感觉,但其实是我边想边写整理出来的思路。希望你能从中找到一点写 Go 的乐趣,或者至少,下次看 NBA 比赛时,能多留意一下那些比分之外的数字。
