HUTB教务系统助手
这篇文章记录我为什么要做一个教务助手、我希望解决哪些具体问题,为避免泛泛而谈,我会穿插关键实现的简化代码片段。
体验链接在文章的末尾有什么好的建议或者想法可以提出来
为什么要做
1) 每学期都要多次登录、输入验证码、页面跳转,还会失败。
2) 在手机上放大缩小、切换学期、误触返回,操作路径长且容易中断。
3) 把机械步骤和不确定性藏到系统背后,让操作更简单
我想要达成的使用体验
登录尽可能少打扰,不需要输入那该s的验证码。
减少不必要的重复登录。
成绩/课表信息查询可以更方便,更直观。
登录这一块,我做了什么(含代码)
1) 与前端一致的加密(RSA/PKCS1 v1.5)
采用与前端同款的加密算法,抛弃浏览器自动化的黑盒不确定性,用纯算法实现,首次登录也能更快更稳。
密码加密(简化示例)
from Crypto.PublicKey import RSA
import binascii
MODULUS_HEX = "00b5eeb1..." 省略
EXPONENT_HEX = "010001" 65537
n = int(MODULUS_HEX, 16)
e = int(EXPONENT_HEX, 16)
pub_key = RSA.construct((n, e))
KEY_BYTE_LEN = (pub_key.size_in_bits() + 7) // 8 256
CHUNK_SIZE = KEY_BYTE_LEN 2 254
def encrypt_password(plaintext: str) > str:
data = plaintext.encode("utf8")
if len(data) > CHUNK_SIZE:
raise ValueError("password too long")
前端同款填充与小端拼装
padded = bytearray(data)
padded.extend(b"\x00" * (CHUNK_SIZE len(padded)))
plain_int = int.from_bytes(padded, byteorder="little")
cipher_int = pow(plain_int, pub_key.e, pub_key.n)
return binascii.hexlify(cipher_int.to_bytes(KEY_BYTE_LEN, "big")).decode()
```
为什么这样做:
去掉“启动浏览器、跑脚本、等渲染”的额外时间成本。
加密参数可控、行为稳定,失败面更小。
2) 验证码自动识别(算术题)
验证码是算术题,直接在后端识别,减少人工输入与反复。
验证码识别(简化示例)
from openai import OpenAI
import os, base64, re
client = OpenAI(
api_key=os.getenv("DASHSCOPE_API_KEY"),
base_url="https://dashscope.aliyuncs.com/compatiblemode/v1",
)
def recognize_captcha_png(img_bytes: bytes) > str:
b64 = base64.b64encode(img_bytes).decode()
completion = client.chat.completions.create(
model="qwenvlplus",
messages=[{
"role": "user",
"content": [
{"type":"image_url","image_url":{"url": f"data:image/png;base64,{b64}"}},
{"type":"text","text":"这是算术验证码,请只返回最后的数字答案"}
]
}]
)
text = completion.choices[0].message.content.strip()
m = re.findall(r"\d+", text)
return m[0] if m else text
```
策略:
失败就明确提示并允许重试。
只要能自动完成,就尽量不打扰。
3) 统一认证直连与请求构造
不再模拟点击,而是直连 SSO 接口;完成验证码识别后构造表单直发。
登录请求关键路径(简化示例)
import urllib.parse, uuid, json, re, requests
BASE = "https://cas.hutb.edu.cn/lyuapServer"
SERVICE = "http://jwgl.hutb.edu.cn/"
session = requests.Session()
token 头
session.headers.update({"logintoken":"loginToken","loginusertoken": uuid.uuid4().hex + uuid.uuid4().hex})
获取验证码
captcha_resp = session.get(f"{BASE}/kaptcha?uid=")
captcha = captcha_resp.json()
uid = captcha["uid"]
code = recognize_captcha_png(base64.b64decode(captcha["content"].split("base64,")[1]))
构造登录数据
login_data = {
"username": username,
"password": encrypt_password(raw_password),
"service": SERVICE,
"loginType": "",
"id": uid,
"code": code,
}
form = urllib.parse.urlencode(login_data)
resp = session.post(f"{BASE}/v1/tickets", data=form, headers={"ContentType":"application/xwwwformurlencoded"})
ticket = resp.json().get("ticket")
完成 SSO
sso = session.get(f"{SERVICE}?ticket={ticket}", allow_redirects=True)
提取 ticket1(用于后续快速登录)
m = re.search(r"ticket1=([^&]+)", sso.url) or next((re.search(r"ticket1=([^&]+)", r.url) for r in sso.history if "ticket1=" in r.url), None)
ticket1 = m.group(1) if m else None
```
这样做的好处:
跳过页面层的不可控,直接进行认证流程。
ticket/ticket1 替代重复登录,后续访问更快。
4)成绩与课表:只取必要、以稳为先
成绩获取
用已登录的会话直接 POST 到成绩查询接口,返回原始 HTML。
成绩查询(简化示例)
BASE = "http://jwgl.hutb.edu.cn"
SCORE_URL = f"{BASE}/jsxsd/kscj/cjcx_list"
QUERY_URL = f"{BASE}/jsxsd/kscj/cjcx_query?Ves632DSdyV=NEW_XSD_XJCJ"
def get_scores_html(username: str, semester: str | None):
s = requests.Session()
cookies = get_user_cookies(username)
if not cookies:
return False, "未找到用户 cookies"
for k, v in cookies.items():
s.cookies.set(k, v)
s.headers.update({
"UserAgent": "Mozilla/5.0 ...",
"ContentType": "application/xwwwformurlencoded",
"Referer": QUERY_URL,
"Origin": BASE,
})
params = {"xsfs": "all"}
if semester:
params["kksj"] = semester
resp = s.post(SCORE_URL, data=params)
if resp.status_code != 200:
return False, f"查询失败,状态码: {resp.status_code}"
html = resp.text
if "请登录" in html or "login" in resp.url.lower():
return False, "会话已过期,请重新登录"
if "课程成绩查询" in html:
return True, html
return False, "无法获取有效成绩数据"
```
课表获取 同理,
按需 POST 参数返回原始 HTML。
```python
课表查询(简化示例)
BASE = "http://jwgl.hutb.edu.cn"
SCHEDULE_URL = f"{BASE}/jsxsd/xskb/xskb_list.do"
def get_schedule_html(username: str, semester: str | None):
s = requests.Session()
cookies = get_user_cookies(username)
if not cookies:
return False, "未找到用户 cookies"
for k, v in cookies.items():
s.cookies.set(k, v)
s.headers.update({
"UserAgent": "Mozilla/5.0 ...",
"ContentType": "application/xwwwformurlencoded",
"Referer": BASE,
"Origin": BASE,
})
params = {}
if semester:
params["xnxq01id"] = semester
resp = s.post(SCHEDULE_URL, data=params)
if resp.status_code == 200:
return True, resp.text
return False, f"查询失败,状态码: {resp.status_code}"
```
优先返回原始结构,减少因前端结构调整导致的脆弱性。
网络一般时也能尽快反应。
安全
仅用于学习和个人查询,遵循学校使用规范与法律法规。
会话有有效期与设备校验,降低被他人滥用的风险。
日志以排障为目的,避免不必要的敏感数据存储。
接下来我会继续做的
成绩变动提醒
一键教评
考试安排查询
..............
最后
这个教务助手的设计初衷很简单:优化体验,而不是取代系统。它的目标不是改变系统本身,而是让常用功能用起来更顺手,让我们的教务网变简单自然由于个人开发者能力和资源有限,当前服务器配置并不是很好,可能会出现以下情况:访问速度较慢、响应时间较长、偶尔服务中断 请理解
参考链接
在线服务入口:[hutb.pansoul.xyz]
项目地址:GitHub