Metadata-Version: 2.4
Name: gxu-jwxt
Version: 0.1.0
Summary: 广西大学教务管理系统 Python SDK — 登录、成绩、课表、选课、考试、个人信息与级联字典
Author-email: sleeper01 <sleeper01@foxmail.com>
License-Expression: MIT
Project-URL: Homepage, https://github.com/halfaradish/GxuJwxtPythonSDK
Project-URL: Repository, https://github.com/halfaradish/GxuJwxtPythonSDK
Project-URL: Issues, https://github.com/halfaradish/GxuJwxtPythonSDK/issues
Project-URL: Documentation, https://github.com/halfaradish/GxuJwxtPythonSDK/blob/main/README.md
Keywords: gxu,jwxt,教务系统,正方教务,广西大学,sdk
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Education
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: httpx>=0.27.0
Requires-Dist: pycryptodome>=3.20.0
Provides-Extra: dev
Requires-Dist: pytest>=8.0; extra == "dev"
Requires-Dist: build>=1.2; extra == "dev"
Requires-Dist: twine>=5.0; extra == "dev"
Dynamic: license-file

# gxu-jwxt

广西大学（GXU）正方教务管理系统 V9.0 的 Python SDK，封装鉴权、成绩、课表、选课、考试、个人信息、课程查询、评教、调课通知与级联字典等业务域，共整理 613 个接口（见 [api.md](https://github.com/halfaradish/GxuJwxtPythonSDK/blob/main/api.md)）。

**它最值钱的部分是鉴权。** 正方的登录链路要求把密码用 RSA(PKCS1_v1_5) 加密、带 `csrftoken` 提交、跟随 302 落到菜单页，会话过期时还会用「HTTP 200 + 一整页登录页」伪装成功——这些都由 SDK 兜住，登录会话默认落盘复用。所以除了用现成的业务域，你也可以**只借它的登录**，之后用 `client.get()` / `client.post()` 自己构造请求，见下文「只借道鉴权」。

- 纯 Python，运行时只依赖 `httpx` 与 `pycryptodome`
- 只需一个同步客户端，无异步分支
- 登录会话默认落盘复用，批量抓取不必每轮重新登录
- 自带命令行工具 `gxu-jwxt`
- 所有接口调用方式都经过对真实部署的实测校验，并由 `tests/test_live_endpoints.py` 回归守卫

> **学期参数**：所有域的 `semester=` 都接受 `"2026-2027-1"`、`"2026"`、`("2026", "3")`
> 等写法，留空表示当前学期。正方内部要的是 `xnm`(学年) + `xqm`(3=秋/12=春/16=小学期)，
> 转换由 `_base.resolve_term()` 统一完成。

> 本项目仅供个人学习与查询本人教务数据使用。请遵守学校相关规定，不要用于高频请求、代刷或其他违规用途。

## 安装

```bash
pip install gxu-jwxt
```

开发安装（含测试依赖）：

```bash
make dev-install      # 等价于 pip install -e ".[dev]"
```

要求 Python >= 3.10。

## 快速上手

```python
from gxu_jwxt import JwxtClient

with JwxtClient(username="学号", password="密码") as client:
    client.auth.login()

    scores = client.score.get_scores(semester="2025-2026-1")
    gpa = client.score.get_gpa_stats()
    schedule = client.schedule.get_personal_schedule(week=5)
    exams = client.exam.get_exam_schedule()
    profile = client.profile.get_profile()
```

凭据与站点配置的优先级：**构造参数 > 环境变量 > 配置文件 > 默认值**。

```bash
export JWXT_USERNAME=学号
export JWXT_PASSWORD=密码
```

```python
from gxu_jwxt import JwxtClient, JwxtConfig

config = JwxtConfig.from_file("config.json")   # {"username": ..., "password": ...}
client = JwxtClient(config=config)
```

## 只借道鉴权：自己构造请求

正方这套系统的重头戏在鉴权，业务接口本身都是普通 HTTP，只是坑不少：

- 密码要先用 `/xtgl/login_getPublicKey.html` 下发的公钥做 RSA(PKCS1_v1_5) 加密，再带着 `csrftoken` 提交；
- 登录前要先 POST 一次 `login_logoutAccount.html` 清掉旧会话，登录后要跟随 302 落到菜单页，才算真正拿到 JSESSIONID；
- **会话过期不会给你 401**：服务端对任何接口都回 200，body 是一整页登录页；
- 个别接口（如平时分明细）少传 `gnmkdm` 会被当成非法请求，直接把整个会话踢下线。

`client.auth.login()` 把这些全做完了，会话还会落盘复用（见下文「会话持久化」）。所以哪怕只想查一个 SDK 还没封装的接口，也不必自己再写一遍登录：

```python
from gxu_jwxt import (
    JwxtClient, resolve_term,
    parse_json_response, is_success_response, response_message,
)

with JwxtClient(username="学号", password="密码") as client:
    client.auth.login()                       # 只有这一句和鉴权有关

    xnm, xqm = resolve_term("2025-2026-1")    # 正方要的是 xnm="2025" + xqm="3"

    # 相对路径自动补 /jwglxt 前缀；会话过期会自动重登录后重试一次
    resp = client.post(
        "/query/query_cxEjjxcdlbList.html",   # 例：空闲教室查询（SDK 未封装），参数请自行抓包核对
        data={"xnm": xnm, "xqm": xqm, "jc": "3"},
    )

    data = parse_json_response(resp.text)     # 兼容裸 JSON / HTML 内嵌 JSON / 登录页
    if not is_success_response(data):         # 认 flag/status/code/success 四种成功标记
        print("失败:", response_message(data))
```

几点说明：

- `client.get()` / `client.post()` 接受相对路径或完整 URL（自动拼 `base_url` + `path_prefix`），自动带上当前 cookie，自动处理会话过期并重登录一次；网络错误抛 `NetworkError`，会话彻底失效抛 `SessionExpiredError`。需要 `csrftoken` 的写操作取 `client.csrf_token`。
- 上面 4 个助手函数从包根导出；`gxu_jwxt._base` 里还有更多实测用过的纯函数（如 `current_term()`、`parse_week_range()`、`extract_csrf_token()`）。
- **参数形态要自己核对**：正方的很多参数写错不会报错，只会静默返回空数据。各业务域实测确认过的形态见 [AGENTS.md](https://github.com/halfaradish/GxuJwxtPythonSDK/blob/main/AGENTS.md) 的接口表，全量接口清单见 [api.md](https://github.com/halfaradish/GxuJwxtPythonSDK/blob/main/api.md)。
- 不想用 httpx 的话，会话以 JSON 形式存在 `client.session_file`（含 cookies 与 csrftoken）；进程内也可以走 `client._http.cookies`（私有属性，不保证稳定）。

## 业务域

常规查询直接用下面的业务域；接口还没封装时，按上一节用 `client.get()` / `client.post()` 自己发。

| 域 | 入口 | 说明 |
|----|------|------|
| 认证 | `client.auth` | 登录、注销、验证码、csrftoken |
| 成绩 | `client.score` | 成绩列表、平时分构成、绩点统计、学分统计、分页遍历 |
| 课表 | `client.schedule` | 个人/班级课表、节次时间表、当前学期与年级、单双周解析 |
| 选课 | `client.selection` | 选课开关发现、可选课程、`quick_enroll()`、`enroll_with_retry()`、退课 |
| 考试 | `client.exam` | 考试安排、准考证 |
| 个人信息 | `client.profile` | 学籍信息（档案页 HTML 解析）、照片、学年列表 |
| 级联字典 | `client.dictionaries` | 学院、专业、专业方向、班级 |
| 课程查询 | `client.courses` | 开课课程库查询（课程号/教师/时间地点/开课状态） |
| 评教 | `client.evaluation` | 评教任务、问卷解析、请求体生成（提交默认 dry-run） |
| 调课通知 | `client.notification` | 调课/停课通知，支持只看未读 |

### 成绩与平时分

```python
scores = client.score.get_scores(semester="2025-2026-1")
scores = client.score.get_scores(course_type="必修")        # 本地按课程性质筛选
gpa    = client.score.get_gpa_stats()                       # 加权绩点、平均分、通过/未通过
stats  = client.score.get_credit_stats()                    # 服务端按课程类别的学分统计

# 平时分构成（分项 / 占比 / 成绩）；未录入时返回空列表
schedule = client.schedule.get_personal_schedule()
items = client.score.get_usual_score(schedule.entries[0].teaching_class_id)
for i in items:
    print(i.name, i.ratio, i.score)      # 平时成绩 40 97.3 / 期末成绩 60 78 / 总评 None 86
```

### 选课

```python
# 选课开关（xkkz_id）与 do_jxb_id 都从「已选课程」里下发，SDK 会自动取用
switches = client.selection.get_selection_switches()
print(switches[0].course_type_name, switches[0].switch_id)

courses = client.selection.get_available_courses(course_type_code="01", keyword="数学")

result = client.selection.quick_enroll("1071206")        # 一键选课（自动挑教学班 + 冲突检查）
if result.success:
    print(result.course_name)

client.selection.enroll_with_retry("1071206", max_retries=10, interval=2)   # 名额满时重试
client.selection.quick_drop("1071206")
```

> 选课/退课只在选课窗口开放期间有效，窗口外服务端返回 `无操作权限！`。
> `get_available_courses()` 在窗口外会抛 `RuntimeError` 并带上服务端原话，
> 而不是静默返回空列表。

### 评教

```python
tasks = client.evaluation.get_pending_evaluations()      # 只列未评的
for task, payload in client.evaluation.auto_evaluate(option_index=0, dry_run=True):
    print(task.course_name, task.teacher_name, len(payload["modelList"][0]["xspjList"]))

# 确认无误后再真正提交（不可逆）
client.evaluation.auto_evaluate(option_index=0, comment="讲解清晰", dry_run=False)
```

### 调课通知

```python
notices = client.notification.get_notifications(only_unread=True)
for n in notices:
    print(n.published_at, n.title)
```

### 级联字典

```python
colleges = client.dictionaries.get_colleges()          # 32 个学院
majors   = client.dictionaries.get_majors()            # 281 个专业（全量）
dirs     = client.dictionaries.get_major_directions()  # 181 个专业方向
classes  = client.dictionaries.get_classes()           # 3455 个班级（全量）

# 按学院筛选请在本地过滤，不要传 jg_id
mech = next(c for c in colleges if c.name == "机械工程学院")
my_classes = [c for c in classes if c.college_id == mech.college_id]
```

**为什么不能用 `jg_id` 过滤**：服务端只会返回仍存在于学院列表里的数据。实测 3455 个班级中有 74 个挂在 6 个已撤销学院下（中加国际学院、资源与冶金学院、材料科学与工程学院、环境学院、教育学院、研究生），逐学院查询并集只有 3381 个，这 74 个永远查不到。全量结果自带 `college_id`/`college_name`、`major_id`/`major_name`、`grade` 等归属字段，本地筛选无损。

## 会话持久化

登录成功后 cookie 会写入磁盘，下次启动时 `client.auth.login()` 先尝试复用，有效就只发 1 次探测请求（而不是 4~5 次登录请求），失效则自动回退完整登录。批量抓取脚本无需改动即可受益。

```python
client.session_file            # 当前会话文件路径
client.save_session()          # 手动写盘
client.restore_session()       # 手动恢复（validate=False 为乐观恢复，不发请求）
client.clear_saved_session()   # 删除会话文件
```

| 配置 | 默认 | 说明 |
|------|------|------|
| `persist_session` | `True` | 置 `False` 完全关闭（不读也不写） |
| `session_file` | `./.jwxt_session.json` | 相对路径以客户端构造时的工作目录为基准 |

对应环境变量 `JWXT_PERSIST_SESSION`（`0`/`false` 关闭）与 `JWXT_SESSION_FILE`。

> **安全提示**：会话文件包含等同于账号凭据的 JSESSIONID。POSIX 下写入权限为 0600（Windows 无法限制），请把它加入你的 `.gitignore`，不要提交到版本库。注销时文件会被自动删除。

服务端仍会按自身策略让空闲会话过期（正方通常 30 分钟~2 小时），持久化消除的是"每轮重新登录"，不是让会话永生。

## 命令行

```bash
gxu-jwxt -u 学号 -p 密码 score list --semester "2025-2026-1"
gxu-jwxt score gpa                                  # 绩点 + 按类别的学分统计
gxu-jwxt score usual --semester "2025-2026-1" --keyword 面向对象
gxu-jwxt schedule show --week 5
gxu-jwxt schedule periods                           # 节次时间表
gxu-jwxt schedule term                              # 当前学期与学生年级
gxu-jwxt selection list --type 01
gxu-jwxt selection switches                         # 选课开关（xkkz_id）
gxu-jwxt selection quick-enroll 1071206
gxu-jwxt courses query --keyword 编译
gxu-jwxt notification list --unread
gxu-jwxt evaluation list
gxu-jwxt evaluation auto --option 0                 # 预演，不加 --submit 不会提交
gxu-jwxt exam list
gxu-jwxt profile show -v                           # 档案页全部字段
gxu-jwxt profile years                            # 学年列表
gxu-jwxt dictionaries colleges
gxu-jwxt dictionaries classes --njdm-id 2026
gxu-jwxt session show            # 查看已保存的会话（不显示 cookie 值）
gxu-jwxt session clear           # 删除已保存的会话
```

凭据优先级：`-u/-p` > 环境变量 > 当前目录的 `config.json`。全局参数：`--base-url`、`--timeout`、`--session-file`、`--no-session`。

## 接口目录

- [api.md](https://github.com/halfaradish/GxuJwxtPythonSDK/blob/main/api.md) —— 613 个接口按业务分类的清单，含登录流程与命名约定
- [discovered_apis.json](https://github.com/halfaradish/GxuJwxtPythonSDK/blob/main/discovered_apis.json) —— 原始爬取结果（菜单 + 接口）

两者由 `crawler.py` 生成，重新生成需要真实账号：

```bash
python crawler.py     # 登录 → 遍历 32 个模块页 → 扫描全部 JS → 提取接口
```

爬虫只依赖 `httpx`（SDK 已有依赖），无需额外安装。接口目录中的会话令牌（如照片接口的 `encodeXhid`）会自动脱敏。

## 开发与测试

```bash
make dev-install                                    # 安装到项目 venv
make test                                           # python -m pytest tests/ -v

# 没装依赖时的替代方式
PYTHONPATH=src python -m pytest tests/ -q
```

- 单元测试不联网：`tests/test_session_e2e.py` 会在本地起一个假教务服务器验证 cookie 全链路
- 联调用例需要环境变量，未设置则自动跳过（**都是只读的**，不会选课、退课或提交评教）：

```bash
JWXT_USERNAME=学号 JWXT_PASSWORD=密码 python -m pytest tests/test_choose.py -v
JWXT_USERNAME=学号 JWXT_PASSWORD=密码 python -m pytest tests/test_live_endpoints.py -v
```

`test_live_endpoints.py` 把每个接口的参数形态都固化成了回归守卫。改完任何域之后
跑一次：正方的很多参数写错不会报错，只会静默返回空数据，这些用例就是为了让那种
回归立刻红灯。

架构、模块划分与各接口的实测踩坑记录见 [AGENTS.md](https://github.com/halfaradish/GxuJwxtPythonSDK/blob/main/AGENTS.md)。

### 已知限制

- **选课/退课的写入路径未实测**：选课窗口关闭时服务端一律返回「无操作权限」，
  成功路径的参数形态来自可工作的同类实现与 `test_app/在分批次选课时提前选课.md`，
  需在选课开放期自行验证。
- **评教问卷结构未实测**：实测账号的评教已全部完成、问卷页返回「已过评价时间！」，
  解析器按同类实现的字段名编写，识别不到时会保留原始 HTML 供人工查看。
- **没有「当前周」接口**：`get_current_week(start_date=...)` 按校历首日本地推算，
  不传首日返回 0。
- **没有学期列表接口**：`get_current_term()` 从课表响应的 `xsxx` 块读取；
  学年列表可以退而求其次，从档案页的下拉框读（`client.profile.get_available_years()`）。
- **成绩列表的学期筛选正常，但某学期可能确实没有数据**：实测该部署上
  2025-2026-1 在成绩查询模块返回 0 条，尽管同一门课的平时分明细里有总评成绩。
  这是学校的成绩发布状态，不是参数问题（2024-2025 学年两个学期分别返回 14 / 13 条）。

## 许可

[MIT](https://github.com/halfaradish/GxuJwxtPythonSDK/blob/main/LICENSE)

仓库：<https://github.com/halfaradish/GxuJwxtPythonSDK>
