HUTB教务系统助手

142

这篇文章记录我为什么要做一个教务助手、我希望解决哪些具体问题,为避免泛泛而谈,我会穿插关键实现的简化代码片段。

体验链接在文章的末尾有什么好的建议或者想法可以提出来

为什么要做

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