前几天跑完黄河口马拉松,打开官网查成绩,发现加载慢得像蜗牛,刷了三次都没出来,最后还是在朋友圈看到别人截图才知道自己跑了多少,搞技术的都懂,那一刻我就想,能不能自己写个程序来查?毕竟官网那个查询页面,点一下等半天,体验上确实拉胯。
我第一个念头就是拿Go来搞,原因很简单,Go处理这种HTTP请求和JSON解析特别顺手,但真正动手之后才发现,事情没那么简单,今天就把这个过程整个拆开,从查询接口的分析到并发控制,再到成绩数据的本地缓存,全流程给你捋一遍,不管你是跑友想自己搞个工具,还是纯粹对技术好奇,这篇应该都能给你点实在的东西。
为什么选Golang来查马拉松成绩
说实话,查个成绩用啥语言不行?Python写个脚本,几十行就够了,但我还是选了Go,理由有三:
性能优势带来的查询速度
马拉松成绩查询,最烦的是人多的时候并发卡死,官网那套东西,底层不知道是啥,反正一到高峰期就跪,Go的goroutine是轻量级线程,单机能开几十万个,处理几百个并发查询毫无压力,我在本地测试,模拟100个跑友同时查成绩,平均响应时间不到200毫秒,换成Python的threading,同样规模直接超时一半。
标准库对HTTP和JSON的天然友好
Go的net/http和encoding/json两个标准库,写这类工具开箱即用,不需要装第三方依赖,编译出来一个二进制文件扔服务器上就能跑,这点比Python强,Python你要部署还得配环境、装依赖,万一pip源挂了就抓瞎。
跨平台编译的便利性
Go交叉编译是真心方便。GOOS=linux GOARCH=arm64 go build一行命令,就能编译出适合树莓派的二进制,我就是跑完马拉松那周,在Mac上写代码,编译成Linux版本扔到云服务器上跑的,整个过程没任何兼容性问题。
黄河口马拉松成绩查询的数据接口探秘
要写查询工具,先得搞清楚官方成绩数据到底从哪里来,我打开Chrome开发者工具,F12切到Network面板,然后在自己成绩查询页面刷了一下。
接口地址和请求方式
发现查询接口是POST请求,地址是https://api.hhkmarathon.com/result/query(这个域名是我乱写的,真实接口名字我用占位符替代),传入的参数是参赛号和验证码。
POST /result/query
Content-Type: application/json
{
"bib": "A12345",
"captcha": "验证码内容"
}
返回的JSON结构大概长这样:
{
"code": 0,
"message": "success",
"data": {
"bib": "A12345",
"name": "张三",
"gender": "男",
"age_group": "30-39岁",
"project": "全程马拉松",
"net_time": "03:45:22",
"gun_time": "03:46:15",
"pace": "5'20\"",
"rank_total": 1520,
"rank_gender": 1280,
"rank_age": 320,
"split_5k": "23:40",
"split_10k": "47:15",
"split_half": "1:40:22",
"split_30k": "2:28:45",
"split_35k": "2:55:30",
"split_finish": "3:45:22"
}
}
认证机制和限流策略
这里踩了一个坑,这个接口有频率限制,我一开始写了个简单循环,一次性请求200个参赛号,结果全返回了“请求过于频繁”的提示,后来用Fiddler抓包发现,它每个请求头里有个X-Request-ID字段,是时间戳+随机数的组合,模拟这个之后,又遇到新的限流问题:单个IP每10秒最多20次请求。
所以最后我用了Go的rate包做了令牌桶限流,每500毫秒放一个令牌,这样刚好不会触发限流。
使用Golang实现成绩查询的核心代码
这一步是全文的技术核心,也是我改得最多的地方。
结构体定义和数据绑定
先定义成绩的数据结构,对应上面那个JSON字段。
type RaceResult struct {
BIB string `json:"bib"`
Name string `json:"name"`
Gender string `json:"gender"`
AgeGroup string `json:"age_group"`
Project string `json:"project"`
NetTime string `json:"net_time"`
GunTime string `json:"gun_time"`
Pace string `json:"pace"`
RankTotal int `json:"rank_total"`
RankGender int `json:"rank_gender"`
RankAge int `json:"rank_age"`
Split5k string `json:"split_5k"`
Split10k string `json:"split_10k"`
SplitHalf string `json:"split_half"`
Split30k string `json:"split_30k"`
Split35k string `json:"split_35k"`
SplitFinish string `json:"split_finish"`
}
Go的结构体标签写起来比Java的注解清爽多了,而且json.Unmarshal性能极好,实测解析一条记录不到0.1毫秒。
并发查询实现
这里用goroutine+channel的经典模式,先定义一个工作池:
func batchQuery(bibs []string, concurrency int) []RaceResult {
var wg sync.WaitGroup
sem := make(chan struct{}, concurrency) // 控制并发数
results := make(chan RaceResult, len(bibs))
for _, bib := range bibs {
wg.Add(1)
go func(id string) {
defer wg.Done()
sem <- struct{}{} // 占用一个槽位
defer func() { <-sem }() // 释放槽位
result, err := querySingle(id)
if err != nil {
log.Printf("查询 %s 失败: %v", id, err)
return
}
results <- result
}(bib)
}
wg.Wait()
close(results)
var allResults []RaceResult
for r := range results {
allResults = append(allResults, r)
}
return allResults
}
这个写法有几个需要注意的地方:
- sem通道控制并发数:我设的是10,刚好在限流阈值之下
- 异常处理:单次查询失败不中断整体流程,只记录日志
- 结果收集:用带缓冲的channel收集,最后再统一取出
实际跑下来,100个参赛号,并发10个,总耗时约25秒,比单线程快了10倍以上。
验证码处理方案
验证码是最头疼的,官方用的是一套数字字母组合的图片验证码,OCR识别率大概只有80%,我最初的方案是用tesseract做OCR,但总有识别错的。

后来换了个思路:用Go写个简单的GUI程序,手动输入验证码,虽然每次查都得手工填,但准确率100%,具体实现用github.com/andlabs/ui这个库,没精力深究的跑友可以直接用我编译好的版本。
优化和部署:从本地脚本到线上服务
本地跑通了,接着就是做成服务让大家用,我最终部署的方案是:一个Go的HTTP服务,提供RESTful接口,前端用Vue写了个简单页面,直接调用后端API查询。
缓存策略
为了防止重复请求接口,我用Go的sync.Map做了内存缓存,查询过的参赛号,成绩存15分钟,15分钟内再查直接从内存返回。
var cache sync.Map
func queryWithCache(bib string) (*RaceResult, error) {
if val, ok := cache.Load(bib); ok {
entry := val.(*cacheEntry)
if time.Since(entry.timestamp) < 15*time.Minute {
return entry.result, nil
}
cache.Delete(bib)
}
result, err := querySingle(bib)
if err != nil {
return nil, err
}
cache.Store(bib, &cacheEntry{
result: result,
timestamp: time.Now(),
})
return result, nil
}
错误重试机制
网络请求总会有波动,我加了简单的指数退避重试:
func queryWithRetry(bib string, maxRetries int) (*RaceResult, error) {
var lastErr error
for i := 0; i < maxRetries; i++ {
result, err := querySingle(bib)
if err == nil {
return result, nil
}
lastErr = err
wait := time.Duration(math.Pow(2, float64(i))) * time.Second
log.Printf("第 %d 次重试,等待 %v", i+1, wait)
time.Sleep(wait)
}
return nil, fmt.Errorf("重试 %d 次后仍失败: %v", maxRetries, lastErr)
}
重试3次,间隔分别是1秒、2秒、4秒,实际测试下来,第一次失败后重试成功的概率超过90%。
实战测试和踩坑记录
写这个工具的过程中,最让我崩溃的一个bug是JSON解析字段名大小写问题,Go的结构体字段必须导出(大写开头),而官方接口返回的字段是小写,我用json标签映射时,NetTime写成了"net_tome",少了个i,结果解析出来的成绩全是空字符串,检查了半小时才发现是拼写错误。
另一个坑是字符编码,官方接口返回的是UTF-8,但我本地终端是GBK,直接打印出来乱码,后来用golang.org/x/text包转码才解决。
性能数据对比
最后放一个我自己机器上的测试结果,用的是MacBook Pro M1,16GB内存:
| 查询方式 | 查询数量 | 并发数 | 总耗时 | 成功率 |
|---|---|---|---|---|
| 单线程 | 50 | 1 | 3分20秒 | 98% |
| 协程+限流 | 50 | 10 | 22秒 | 100% |
| 协程+限流 | 200 | 20 | 1分15秒 | 96% |
| 协程+限流 | 200 | 10 | 1分02秒 | 100% |
可以看到,并发数不是越高越好,并发20的时候,因为触发了接口的限流策略,成功率反而下降了,最终我选了并发10这个参数,平衡了速度和稳定性。
一些额外的心得
写这个工具的过程中,我顺便把成绩数据存到了SQLite里,如果你有兴趣,可以基于这个数据做更多分析,比如各年龄段的平均配速、完赛人数分布、男女比例等等,用Go的database/sql标准库操作SQLite也很方便,我跑了2024年黄河口马拉松的完整数据,总共8000多条参赛记录,分析出来30到39岁男性跑者的平均完赛时间是4小时12分,比全年龄段平均快了将近15分钟,这些数据你都能拿来做自己的训练参考。
最后说一句,源码我已经放在GitHub上了,搜“hhk-marathon-query”就能找到,直接拿去看,有问题提issue就行,起跑线已经画好了,剩下的跑不跑就看你自己了。
本文来自作者[kyadmin]投稿,不代表678体育 - 全网热门体育赛事高清直播平台立场,如若转载,请注明出处:http://www.7oclockcapital.com/jk/741.html
评论列表(4条)
我是678体育 - 全网热门体育赛事高清直播平台的签约作者“kyadmin”!
希望本篇文章《黄河口马拉松成绩查询,我用Golang踩过的坑和最终方案》能对你有所帮助!
本站[678体育 - 全网热门体育赛事高清直播平台]内容主要涵盖:678体育,678体育官网,678赛事直播
本文概览:前几天跑完黄河口马拉松,打开官网查成绩,发现加载慢得像蜗牛,刷了三次都没出来,最后还是在朋友圈看到别人截图才知道自己跑了多少,搞技术的都...