一个分数统计QQ机器人
22考研分数刚出,学校建了个实验室宣传群,我也接过老师的旨意,进去宣传。陆续有考生进群,浙江省未公布排名,所以考生的一大痛点是:知道总体排名情况,提前判断自己是否能进复试。去年我写过一个 python 爬虫分数统计,用起来不是那么舒服。今年我突然想到,直接用 QQ 机器人应该会优雅很多。后面群里人都在用,还蛮受宠若惊的,顺便写个文档,方便下届复用。 Github 传送门。
一、需求分析与实现设计
1.1 总体思路
- 统计来源:使用 MiraiGo 获取群内所有成员的名片信息,从名片信息内提取分数,统计
- 交互方式:通过群内聊天关键词触发,获取与计算分数,发送结果
1.2 需求分析
- 需要统计各个分数段的人数,每10分为一段,并从高到低展示
- 为了兼容以及可重用,应当在设计初期就支持多群独立统计
- 虽然都是计算机学院,但是分为计算机学硕,计算机专硕,软件工程学硕等等六个专业,需要独立排名。
1.3 实现设计
1.3.1 QQ 机器人 API 相关
以下功能是通过 QQ 机器人的 API 实现的,本文略。
获取群列表、获取某个群所有成员的QQ以及群名片、群消息识别、群消息发送。
1.3.2 多群独立统计分数
很简单,弄个 map 就好,key 是群号,value 是相应的分数排名结构体
1.3.3 各专业独立排名,并方便拓展
已知,大部分考生的群名片都按要求改过。如,计算机学硕的同学,名片中会含有「计学」或「计科」。
我不想写太多过程式的代码,很不优雅,维护也非常麻烦,所以采用了接口的实现方式。
构思是这样的,其实很简单。
如果采用过程式的代码,计算某一个群的各专业分数排名,大概是这样的:
// (输入)群名片列表:[]string
type (
major = string
score = int
)
// (输出)各专业分数列表: map[major][]score
// 方法:遍历群名片列表,如果群名片含有「计学」字样,并且能正则出一个三位数,就 append 一下 map["计学"] 的切片。如果含有「计专」,就...;如果...,就...
所以,每个专业的其实只有识别规则与专业名字不同,那就将其约定成为一个接口。
每想实现一个新的专业的识别,就定义一个新的实现了该接口的过滤规则,传进去就好。
代码如下:
// ScoreFilter 专业过滤接口
type ScoreFilter interface {
Name() string // 专业名字
Filter(nickname string) bool // 专业匹配规则
}
// GetScoreMap 传入昵称列表和过滤规则,返回每个规则命中的分数列表
func GetScoreMap(nicknames []string, filters ...ScoreFilter) map[ScoreFilter][]int {
scoreMap := make(map[ScoreFilter][]int)
for _, nickname := range nicknames {
for _, filter := range filters {
if filter.Filter(nickname) {
// ExtractScore 就是从 string 中尝试匹配一个三位数
score, ok := ExtractScore(nickname)
if ok {
scoreMap[filter] = append(scoreMap[filter], score)
}
}
}
}
// 分数排序
for _, scores := range scoreMap {
sort.Slice(scores, func(i, j int) bool {
return scores[i] > scores[j]
})
}
return scoreMap
}
后面输出各专业标头的时候,调用 ScoreFilter.Name()
即可。
1.3.4 统计各分数段人数
我实现的是经典的 10 分一段。
存储每个分数段的数据结构是这样的:
type ScoreGroup struct {
Min int // 最小分数
Max int // 最大分数
scores []int // 分数列表
}
func (s ScoreGroup) Describe() string {
return fmt.Sprintf("%v - %v", s.Min, s.Max)
}
func (s ScoreGroup) Len() int {
return len(s.scores)
}
分段方法很简单:
// 选用第一个数,抹个位,作为 start
var start int = scores[0] / 10 * 10
// start + 9,作为 end,这样就能实现类型 350-359 的分数区间
var end int = start + 9
// 当前分数段内的所有分数
scoresOneGroup := make([]int, 0)
遍历分数切片(已从大到小有序)
- 若当前分数在 [start, end] 内,就加到当前分数段内
- 否则,说明上一个分数段满了。将上一个分数段的所有分数存储到总的分数段结构体内,再根据当前分数调整 start 与 end,清空当前分数段分数,将当前分数段加到新的当前分数段切片内。
- 记得最后一组分数段,也要加到总分数段结构体内。
1.3.5 最终效果
考研分数段统计来啦!
计算机学硕(共92个分数)
390 - 399: 1人(累计1)
//...略
330 - 339: 12人(累计39)
320 - 329: 10人(累计49)
//...略
260 - 269: 1人(累计92)
计算机专硕(共357个分数)
390 - 399: 1人(累计1)
380 - 389: 11人(累计12)
370 - 379: 7人(累计19)
//...略
二、过密分数段分布
随着进群人数的增多,发现某些分数段过于密集,某些10分段内的人数甚至达到了30+人,参考意义不大。所以做了个过密分数段分布。
写这个机器人的时候,我个人愈发觉得,应该多用复杂的数据结构解决问题,而不是用复杂的算法解决问题。因为复杂的数据结构能简化思考,减少错误发生的可能性,且方便拓展;而复杂的算法,非常容易出错,且难懂,难改。
(比如因为算分数段的时候,发生过一次计数偏移现象,导致几个学生可能看错了排名,感觉蛮严重的,线上使用且人数较多的程序,感觉还是不要写成算法题的样子吧)
所以我干脆换了分数段统计的方法,直接粗暴得统计每个分数的人数,然后再分组。
// ScoreCount 记录每个分数的数量
type ScoreCount struct {
Score int
Count int
}
type ScoreGroupCount struct {
Min int // 最小分数
Max int // 最大分数
Scores []ScoreCount // 分数列表
}
定义数据结构的一大好处是,能在其基础上定义方法,将功能模块化拆分。
最后,计算密集分数段只要这样一行链式操作:
// 输入: var scores []int
denseGroups := ScoreGroupCountList(GroupScoresFromCount(CountScores(scores))).FilterByCount(10)
// CountScores(scores) 计算每个分数的人数
// GroupScoresFromCount(...) 从上面的结果中十分一段分组
// ScoreGroupCountList(...) 做一个类型转换
// ScoreGroupCountList(...).FilterByCount(10) // 过滤出超过10人的分段
三、输出为网页
之前的输出方式,都是群内发送「:score」或「分数实时排名」,就会计算该群分数信息,并直接发送查询结果到群内。但是因为分数信息实在太多了,导致每次查询分数,都会直接占满两屏,刷屏太影响其他人的聊天。
所以我在寻求另外的展示方式。
之前想过私聊,但这存在需要问题:
- 需要开启群内临时会话,不安全,容易滋生广告
- 消息不共享,增加调用次数,容易被风控。如果在群内,一个人查了其他人都能看到;如果私聊,那么每个人想查都要调用一次,群里700多个人,不仅耗费资源,还容易导致机器人被风控。
- 临时会话问题。如果非好友,向机器人发送的消息属于临时会话,可以获取到来源的群号。如果是好友,消息属于私聊消息,除了存在两种不同消息需要兼容问题,还可能存在私聊消息不知道他来源于哪个群的问题(所以就无法获取其群名片,以及排名。一个可能的解决方案是,遍历机器人所在的所有的群,找到有该人的群,获取其名片)
私聊发送分数统计信息,明显不优雅。
我想到了将查询结果输出为网页的方式:将结果写入到 html 文件中,直接访问网页。
但这又有很多问题,直接修改文件,明显是不优雅的,而且可移植性非常低。
我就在想,有没有这样一个服务,能托管 html,并且支持 api 修改内容?很遗憾的是,找了半天没找到。
灵光一现!
我直接起个 gin web 服务,暴露一个 GET 查询,返回 c.String()
,然后反向代理一下,不就相当于一个网页了吗?因为通过网址访问网页,用的就是 http.GET
! 再设置一个 Query
参数,通过群号访问各个群的独立排名。
在QQ群端,接到指令,重新排名。拼接 base_url
与群号,返回用户直接可点击的网址。
最终效果如下: