给广州商学院教务系统写了个成绩导出脚本,从此再也不用手抄成绩了
“软件工程3班的苏增烨,你这个Excel里的总分,怎么比系统里少了整整20分?张三同学的平时分是被你吃了吗?”
当专业课老师在讲台上,当着全班六十多号人的面,推了推眼镜对我说出这句话时,我感觉自己仿佛被钉在了广商(广州商学院)第一教学楼的黑板上。作为本门课程的课代表,同时也是一个大四的软工男,我当时的脚趾已经在鞋底抠出了一套三室两厅。
这不是我第一次抄错成绩了,准确地说,是第二次。
为什么我要“抄”成绩?这就要拜我们学校乃至全国无数高校都在使用的“正方教务系统”所赐了。
痛点:被正方教务系统支配的“人工智障”恐惧
如果你也是大学生,那你大概率领教过正方教务系统的威力。这套系统的UI界面仿佛永远停留在2010年,带着一股浓浓的IE6时代复古风。选课的时候它能卡成PPT,查成绩的时候它能转圈转到你怀疑人生。
但最让我崩溃的,是它的“平时成绩”模块。
老师们会在系统里录入每个人的考勤、作业和课堂表现,这些数据在系统后台是完整存在的。但是!这个页面偏偏没有导出按钮!期末的时候,老师为了方便统分和存档,要求我把全班的平时成绩整理成一个Excel发给他。
于是,一个21世纪的软件工程大四学生,面对着屏幕,开始了最原始的赛博体力劳动:Alt + Tab切到浏览器,看一眼张三的成绩,Alt + Tab切回Excel,手动输入;再切回去看李四……
六十多个人,十几次平时成绩,密密麻麻的表格。看久了屏幕,数字都在眼前跳舞。第一次我抄串了行,第二次我漏掉了一个人的某次作业。接连两次被老师当众纠正,老师甚至半开玩笑地说:“你们软工的学生,天天敲键盘,怎么录个数据还能录错?”
伤害性不大,侮辱性极强。
那天晚上回到广商的宿舍,听着室友打瓦的键盘声,我盯着正方教务系统那个简陋的页面,猛灌了一口维他柠檬茶:去你的“人工智障”,老子今天非要把你的底裤扒下来不可。
抓包:扒下这套老古董的底裤
说干就干。我打开Chrome浏览器,按下F12唤出开发者工具,切换到Network(网络)面板,然后重新刷新了平时成绩的页面。
原本我以为,这种老古董系统大概率是那种前后端不分离的JSP页面,我可能得用BeautifulSoup去恶心的HTML标签里一层一层地解析DOM树,提取表格里的<td>标签。
结果抓包一看,我愣住了。
在杂乱的请求中,我发现了一个XHR请求,点开Preview一看,竟然是一个格式非常规整的JSON文件!里面清晰地以键值对的形式列出了student_id、student_name、daily_score_1、daily_score_2……
好家伙,原来这老系统在某次不知名的升级中,居然偷偷披上了一层前后端分离的皮。既然有现成的JSON接口,那事情就简单太多了。
我脑海中立刻浮现出了技术路线:用requests库模拟登录并带上Cookie请求这个接口,拿到JSON数据后,丢给pandas进行数据清洗和整理,最后用openpyxl导出成一个带有边框和加粗表头的完美Excel文件。
踩坑:逆向JS与奇葩的“老师备注”
理想很丰满,写代码的时候就开始踩坑了。
第一个坑是接口鉴权。我直接把抓到的请求URL复制到Postman里,带上Cookie发送,结果返回了一个冷酷无情的403 Forbidden。
仔细检查请求头(Headers),我发现除了Cookie,每次请求还带了一个动态生成的X-Auth-Token。这个Token每次刷新页面都不一样。没有它,服务器就不认你。
为了搞清楚这个Token是怎么来的,我点开Sources面板,搜索了X-Auth-Token这个关键字,顺藤摸瓜找到了一个名为security.js的混淆文件。虽然代码被压缩成了一坨,但经过简单的格式化和断点调试,我很快看穿了它的把戏。
这套系统的安全机制极其“古典”:Token的生成逻辑其实就是 MD5(当前时间戳 + "一串硬编码的加盐字符串")。
我在Python里引入hashlib和time模块,花了两分钟复刻了这个签名逻辑:
import time
import hashlib
def generate_token():
salt = "ZhengFang_Secret_2019!@" # 就是这串奇葩的盐
timestamp = str(int(time.time() * 1000))
raw_str = timestamp + salt
return hashlib.md5(raw_str.encode('utf-8')).hexdigest(), timestamp
带上自己生成的Token和时间戳,再次请求,200 OK!看着终端里打印出来的全班成绩JSON,我露出了龙王般的微笑。
第二个坑出在数据清洗上。
拿到数据后,我用pandas的DataFrame进行处理,准备直接算个总分然后导出。结果程序直接抛出了TypeError。
我排查了半天,发现问题出在老师的录入习惯上。正统的平时分应该是纯数字,但有些老师喜欢把成绩输入框当成备忘录来用!比如在某次作业的成绩里,别人都是85、90,到了某个同学那里,赫然写着一行字:迟到扣5分,本次计75。
pandas在计算这种混杂了字符串的列时直接罢工了。
没办法,我只能写一个正则表达式预处理函数,遍历所有成绩字段,如果遇到纯数字就直接转换,如果遇到包含文字的字符串,就用正则re.findall(r'\d+', str)提取出里面的最后一个数字作为成绩,如果提取不到就默认为0。
最后,为了让导出的Excel看起来足够专业(以挽回我在老师心中的形象),我用openpyxl给表格加上了全黑边框,把表头设置成了微软雅黑加粗,甚至还贴心地加了一列“平时分总计”并自动标红了不及格的分数。
意外走红:连外地学弟都来提Issue了
第二天上课,当老师再次让我统计最新一次的平时分时,我没有打开教务系统。
我当着老师的面,打开终端,输入了 python export_grades.py。
两秒钟后,终端提示:导出成功:软件工程3班平时成绩表.xlsx。
我把那个排版精美、数据绝对零误差的Excel发给老师时,老师看我的眼神都变了。那种眼神,仿佛在看一个终于开窍的智人。
独乐乐不如众乐乐。我把这个脚本打包成了一个带配置文件的工程,发到了我们软工专业的年级群里:“兄弟们,各班课代表自取,再也不用手抄成绩了。”
结果这个小工具的传播速度完全超出了我的预料。不到一天时间,它被转发到了学院的三个不同班级,甚至连隔壁网络工程专业的课代表都跑来加我微信求教怎么配置Python环境。
为了方便大家,我把代码传到了GitHub上,写了一份详细的README。
最让我激动的是大概一周后,我的邮箱收到了一封GitHub的通知——有人给我的仓库提了一个Issue!
提Issue的是一个外地某不知名高校的学弟。他在Issue里说,他们学校也用正方教务系统,但是运行我的脚本报错了。我跟他沟通后发现,他们学校的系统版本稍微新一点,平时成绩的API路径从 /api/v1/score/daily 变成了 /api/v2/score/daily,而且返回的JSON结构多嵌套了一层。
我花了一个多小时,给代码加了一个版本兼容的配置项,顺手帮他把Bug修了。当他在Issue下回复“跑通了!学长牛逼!”的时候,那种纯粹的技术成就感,比我在峡谷里拿了五杀还要爽。
尾声:三本学生的“野生”项目观
现在是大四的秋招季,身边的同学都在为找工作焦头烂额。
说实话,作为广州商学院这样一个民办三本院校的学生,我们有着非常真实的学历焦虑。我们没有985/211的光环,简历投给大厂大概率在HR的初筛系统里就被直接Pass了;我们也没有机会去接触什么“千万级高并发电商架构”、“分布式微服务集群”这种高大上的实习项目。
很多同学为了丰富简历,去网上跟着视频敲一些烂大街的“图书管理系统”或者“外卖点餐后台”,也就是所谓的“简历驱动开发”。
但我看着我GitHub上那个只有区区几个Star的教务系统导出脚本