courser 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- courser-0.1.0/.env.example +18 -0
- courser-0.1.0/.gitignore +22 -0
- courser-0.1.0/.python-version +1 -0
- courser-0.1.0/LICENSE +21 -0
- courser-0.1.0/PKG-INFO +128 -0
- courser-0.1.0/README.md +104 -0
- courser-0.1.0/config.example.json +28 -0
- courser-0.1.0/courser/__init__.py +7 -0
- courser-0.1.0/courser/config.py +176 -0
- courser-0.1.0/courser/course_table.py +123 -0
- courser-0.1.0/courser/editing.py +99 -0
- courser-0.1.0/courser/filters.py +61 -0
- courser-0.1.0/courser/human.py +33 -0
- courser-0.1.0/courser/logfile.py +35 -0
- courser-0.1.0/courser/models.py +116 -0
- courser-0.1.0/courser/notifier.py +220 -0
- courser-0.1.0/courser/opencli.py +291 -0
- courser-0.1.0/courser/pku/__init__.py +1 -0
- courser-0.1.0/courser/pku/client.py +738 -0
- courser-0.1.0/courser/pku/extract.py +74 -0
- courser-0.1.0/courser/pku/parser.py +131 -0
- courser-0.1.0/courser/risk.py +124 -0
- courser-0.1.0/courser/runner.py +236 -0
- courser-0.1.0/courser/scheduler.py +110 -0
- courser-0.1.0/courser/storage.py +157 -0
- courser-0.1.0/courser/tui/__init__.py +1 -0
- courser-0.1.0/courser/tui/__main__.py +5 -0
- courser-0.1.0/courser/tui/app.py +2175 -0
- courser-0.1.0/courser/tui/state.py +79 -0
- courser-0.1.0/courser/tui/theme.py +311 -0
- courser-0.1.0/pyproject.toml +38 -0
- courser-0.1.0/scripts/release.sh +40 -0
- courser-0.1.0/scripts/simulate_seats.py +73 -0
- courser-0.1.0/scripts/smoke_search.py +169 -0
- courser-0.1.0/scripts/test_mail.py +29 -0
- courser-0.1.0/tests/integration/test_round_runner.py +276 -0
- courser-0.1.0/tests/integration/test_warning_detection.py +157 -0
- courser-0.1.0/tests/tui/test_hero_layout.py +146 -0
- courser-0.1.0/tests/tui/test_navigation.py +460 -0
- courser-0.1.0/tests/tui/test_responsive.py +189 -0
- courser-0.1.0/tests/tui/test_settings_mail_confirm.py +184 -0
- courser-0.1.0/tests/tui/test_table_layout.py +215 -0
- courser-0.1.0/tests/tui/test_text_selection.py +284 -0
- courser-0.1.0/tests/unit/test_client_session.py +158 -0
- courser-0.1.0/tests/unit/test_config.py +70 -0
- courser-0.1.0/tests/unit/test_editing.py +26 -0
- courser-0.1.0/tests/unit/test_filters.py +68 -0
- courser-0.1.0/tests/unit/test_notification.py +200 -0
- courser-0.1.0/tests/unit/test_once_render.py +71 -0
- courser-0.1.0/tests/unit/test_opencli.py +82 -0
- courser-0.1.0/tests/unit/test_parser.py +123 -0
- courser-0.1.0/tests/unit/test_risk.py +65 -0
- courser-0.1.0/uv.lock +122 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# ===== courser 敏感配置模板 =====
|
|
2
|
+
# 复制为 .env(.env 已被 .gitignore 忽略),或直接在 TUI「设置」里填写。
|
|
3
|
+
|
|
4
|
+
# ---- PKU 学号/密码(可选)----
|
|
5
|
+
# 不配置则依赖浏览器密码管理器自动填充(打开登录页等待一小会直接点登录)。
|
|
6
|
+
# 注意:在 opencli 自动化窗口中,少数环境密码管理器不会自动填充,
|
|
7
|
+
# 此时必须在这里(或 TUI 设置)配置,脚本会先填好再点登录。
|
|
8
|
+
# PKU_USERNAME=
|
|
9
|
+
# PKU_PASSWORD=
|
|
10
|
+
|
|
11
|
+
# ---- 邮件通知(gws = Google Workspace CLI)----
|
|
12
|
+
# 发送邮件走 gws,需先安装并授权:
|
|
13
|
+
# brew install googleworkspace-cli 或 npm install -g @googleworkspace/cli
|
|
14
|
+
# gws auth setup (首次:初始化 Google Cloud 项目 / OAuth / 启用 API)
|
|
15
|
+
# gws auth login (浏览器完成 OAuth2 授权)
|
|
16
|
+
# 收件邮箱在 TUI「设置 → 邮件通知」填写(也可用环境变量覆盖):
|
|
17
|
+
# MAIL_TO=you@example.com
|
|
18
|
+
# GWS_FROM= # gws 发件账号(Gmail 地址,可选,默认取认证账号)
|
courser-0.1.0/.gitignore
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# ---- 运行时产物(含凭据/邮件配置,绝不入库) ----
|
|
2
|
+
config.json
|
|
3
|
+
.env
|
|
4
|
+
data/
|
|
5
|
+
*.log
|
|
6
|
+
.gws-*/
|
|
7
|
+
|
|
8
|
+
# ---- Python / uv ----
|
|
9
|
+
.venv/
|
|
10
|
+
.uv-cache/
|
|
11
|
+
__pycache__/
|
|
12
|
+
*.py[oc]
|
|
13
|
+
build/
|
|
14
|
+
dist/
|
|
15
|
+
wheels/
|
|
16
|
+
*.egg-info
|
|
17
|
+
.pytest_cache/
|
|
18
|
+
|
|
19
|
+
# ---- 编辑器/系统 ----
|
|
20
|
+
.DS_Store
|
|
21
|
+
.idea/
|
|
22
|
+
.vscode/
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.12
|
courser-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 xjsongphy
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
courser-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: courser
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: PKU 补退选空余名额监控:opencli 驱动后台 Chrome,人类节奏轮询,命中筛选即邮件提醒(纯文本持久监控 TUI)
|
|
5
|
+
Project-URL: Homepage, https://github.com/xjsongphy/courser
|
|
6
|
+
Project-URL: Repository, https://github.com/xjsongphy/courser
|
|
7
|
+
Project-URL: Issues, https://github.com/xjsongphy/courser/issues
|
|
8
|
+
Author: xjsongphy
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: pku,reminder,textual,tui,监控,补退选,选课
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Operating System :: MacOS
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
19
|
+
Classifier: Topic :: Utilities
|
|
20
|
+
Requires-Python: >=3.12
|
|
21
|
+
Requires-Dist: rich>=15.0.0
|
|
22
|
+
Requires-Dist: textual>=8.2.8
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# courser
|
|
26
|
+
|
|
27
|
+
北大补退选课程名额监控工具。它在本机 Chrome 中登录选课系统,按设定间隔读取补退选列表;当符合条件的课程出现空余时,发一封邮件提醒。
|
|
28
|
+
|
|
29
|
+
它只做查询和提醒,不会替你选课,也不会处理验证码。
|
|
30
|
+
|
|
31
|
+
> 请只用于自己的账号,并遵守学校的相关规定。登录、查询和翻页之间会保留随机间隔;遇到风控提示或验证码时,程序会停止本轮,而不是继续尝试。
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
# 从 PyPI 安装(只带 rich/textual;opencli/gws 需另装,见下方)
|
|
35
|
+
pipx install courser # 或:uv tool install courser
|
|
36
|
+
|
|
37
|
+
# 或从源码跑
|
|
38
|
+
python -m venv .venv && . .venv/bin/activate
|
|
39
|
+
pip install -e .
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
> 发布到 PyPI 只包含 courser 自身与 `rich`/`textual` 两个 Python 依赖。
|
|
43
|
+
> **opencli / gws 不随包发布,需在本机单独安装**(见下方 Quick start 的说明)。
|
|
44
|
+
|
|
45
|
+
## Quick start
|
|
46
|
+
|
|
47
|
+
需要:Python 3.12+、[uv](https://docs.astral.sh/uv/)、Chrome,以及两个已完成配置的命令行工具:
|
|
48
|
+
|
|
49
|
+
- [OpenCLI](https://github.com/jackwener/OpenCLI):连接本机 Chrome。安装扩展、启动 daemon 后,运行 `opencli doctor`,确认检查通过。
|
|
50
|
+
- [Google Workspace CLI](https://github.com/googleworkspace/cli)(`gws`):发送提醒邮件。
|
|
51
|
+
安装:`npm install -g @googleworkspace/cli`。
|
|
52
|
+
首次先执行 `gws auth setup`(一次性初始化 Google Cloud 项目 / OAuth 配置 / 启用 API),
|
|
53
|
+
再执行 `gws auth login` 授权 Gmail;之后 token 失效时重新 `gws auth login` 即可。
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
git clone https://github.com/xjsongphy/courser
|
|
57
|
+
cd courser
|
|
58
|
+
uv sync
|
|
59
|
+
cp config.example.json config.json
|
|
60
|
+
uv run courser
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
第一次启动会询问收件邮箱。其余设置可以在程序的设置页完成。
|
|
64
|
+
|
|
65
|
+
若你更愿意由 Chrome 或密码管理器填充北大学号和密码,保持 `credentials` 为空即可;也可以写入 `config.json`,或通过环境变量 `PKU_USERNAME`、`PKU_PASSWORD` 提供。
|
|
66
|
+
|
|
67
|
+
## What it does
|
|
68
|
+
|
|
69
|
+
- 每轮从 IAAA 登录,打开补退选页面,并按网站实际页数读取课程;不依赖固定页数。
|
|
70
|
+
- 按课程名、课程类别和开课院系筛选。可选择任一条件命中或全部条件命中。
|
|
71
|
+
- 只有“限数大于已选”的课程才会提醒;同一门课有冷却时间,邮件也有每小时上限。
|
|
72
|
+
- 提供一个终端界面查看最近结果、调整筛选和设置、查看日志。文字选择风格为 Textual
|
|
73
|
+
自接管:鼠标拖动选择、松手即自动复制到剪贴板(OSC 52),并 toast 提示;滚轮滚
|
|
74
|
+
页面,帮助 / 日志 / 详情页均可用。
|
|
75
|
+
|
|
76
|
+
## Run one check
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
uv run courser --once
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
这会跑完一轮并把结果打印到终端,适合确认登录和筛选是否正常。持续运行则直接执行 `uv run courser`。
|
|
83
|
+
|
|
84
|
+
## Configuration
|
|
85
|
+
|
|
86
|
+
复制 `config.example.json` 得到 `config.json` 后,最常需要改的是下面几项:
|
|
87
|
+
|
|
88
|
+
```jsonc
|
|
89
|
+
{
|
|
90
|
+
"interval_min": 8.0, // 查询间隔(分钟)
|
|
91
|
+
"filters": {
|
|
92
|
+
"names": [], // 课程名,支持子串匹配
|
|
93
|
+
"categories": [], // 课程类别
|
|
94
|
+
"depts": ["英语语言文学系"],
|
|
95
|
+
"match": "any" // any:任一条件;all:全部条件
|
|
96
|
+
},
|
|
97
|
+
"notify": {
|
|
98
|
+
"to": "you@example.com",
|
|
99
|
+
"min_interval_min": 15.0,
|
|
100
|
+
"max_per_hour": 5
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
`config.json` 不会提交到 Git。运行日志写在 `data/courser.log`;登录、浏览器连接或邮件发送出问题时,先看这里。
|
|
106
|
+
|
|
107
|
+
## Troubleshooting
|
|
108
|
+
|
|
109
|
+
`opencli doctor` 需要全部通过。若登录页填入账号密码后仍未跳转,先在同一个 Chrome 中手动完成一次登录,再执行 `uv run courser --once`。验证码和二次验证需要你本人处理,courser 不会代填。
|
|
110
|
+
|
|
111
|
+
## Development
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
uv run python tests/unit/test_opencli.py
|
|
115
|
+
uv run python tests/integration/test_round_runner.py
|
|
116
|
+
uv run python tests/tui/test_navigation.py
|
|
117
|
+
uv run python tests/tui/test_responsive.py
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
真实网站的端到端检查仍需手动执行:
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
uv run courser --once
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## License
|
|
127
|
+
|
|
128
|
+
[MIT](LICENSE)
|
courser-0.1.0/README.md
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# courser
|
|
2
|
+
|
|
3
|
+
北大补退选课程名额监控工具。它在本机 Chrome 中登录选课系统,按设定间隔读取补退选列表;当符合条件的课程出现空余时,发一封邮件提醒。
|
|
4
|
+
|
|
5
|
+
它只做查询和提醒,不会替你选课,也不会处理验证码。
|
|
6
|
+
|
|
7
|
+
> 请只用于自己的账号,并遵守学校的相关规定。登录、查询和翻页之间会保留随机间隔;遇到风控提示或验证码时,程序会停止本轮,而不是继续尝试。
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
# 从 PyPI 安装(只带 rich/textual;opencli/gws 需另装,见下方)
|
|
11
|
+
pipx install courser # 或:uv tool install courser
|
|
12
|
+
|
|
13
|
+
# 或从源码跑
|
|
14
|
+
python -m venv .venv && . .venv/bin/activate
|
|
15
|
+
pip install -e .
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
> 发布到 PyPI 只包含 courser 自身与 `rich`/`textual` 两个 Python 依赖。
|
|
19
|
+
> **opencli / gws 不随包发布,需在本机单独安装**(见下方 Quick start 的说明)。
|
|
20
|
+
|
|
21
|
+
## Quick start
|
|
22
|
+
|
|
23
|
+
需要:Python 3.12+、[uv](https://docs.astral.sh/uv/)、Chrome,以及两个已完成配置的命令行工具:
|
|
24
|
+
|
|
25
|
+
- [OpenCLI](https://github.com/jackwener/OpenCLI):连接本机 Chrome。安装扩展、启动 daemon 后,运行 `opencli doctor`,确认检查通过。
|
|
26
|
+
- [Google Workspace CLI](https://github.com/googleworkspace/cli)(`gws`):发送提醒邮件。
|
|
27
|
+
安装:`npm install -g @googleworkspace/cli`。
|
|
28
|
+
首次先执行 `gws auth setup`(一次性初始化 Google Cloud 项目 / OAuth 配置 / 启用 API),
|
|
29
|
+
再执行 `gws auth login` 授权 Gmail;之后 token 失效时重新 `gws auth login` 即可。
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
git clone https://github.com/xjsongphy/courser
|
|
33
|
+
cd courser
|
|
34
|
+
uv sync
|
|
35
|
+
cp config.example.json config.json
|
|
36
|
+
uv run courser
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
第一次启动会询问收件邮箱。其余设置可以在程序的设置页完成。
|
|
40
|
+
|
|
41
|
+
若你更愿意由 Chrome 或密码管理器填充北大学号和密码,保持 `credentials` 为空即可;也可以写入 `config.json`,或通过环境变量 `PKU_USERNAME`、`PKU_PASSWORD` 提供。
|
|
42
|
+
|
|
43
|
+
## What it does
|
|
44
|
+
|
|
45
|
+
- 每轮从 IAAA 登录,打开补退选页面,并按网站实际页数读取课程;不依赖固定页数。
|
|
46
|
+
- 按课程名、课程类别和开课院系筛选。可选择任一条件命中或全部条件命中。
|
|
47
|
+
- 只有“限数大于已选”的课程才会提醒;同一门课有冷却时间,邮件也有每小时上限。
|
|
48
|
+
- 提供一个终端界面查看最近结果、调整筛选和设置、查看日志。文字选择风格为 Textual
|
|
49
|
+
自接管:鼠标拖动选择、松手即自动复制到剪贴板(OSC 52),并 toast 提示;滚轮滚
|
|
50
|
+
页面,帮助 / 日志 / 详情页均可用。
|
|
51
|
+
|
|
52
|
+
## Run one check
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
uv run courser --once
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
这会跑完一轮并把结果打印到终端,适合确认登录和筛选是否正常。持续运行则直接执行 `uv run courser`。
|
|
59
|
+
|
|
60
|
+
## Configuration
|
|
61
|
+
|
|
62
|
+
复制 `config.example.json` 得到 `config.json` 后,最常需要改的是下面几项:
|
|
63
|
+
|
|
64
|
+
```jsonc
|
|
65
|
+
{
|
|
66
|
+
"interval_min": 8.0, // 查询间隔(分钟)
|
|
67
|
+
"filters": {
|
|
68
|
+
"names": [], // 课程名,支持子串匹配
|
|
69
|
+
"categories": [], // 课程类别
|
|
70
|
+
"depts": ["英语语言文学系"],
|
|
71
|
+
"match": "any" // any:任一条件;all:全部条件
|
|
72
|
+
},
|
|
73
|
+
"notify": {
|
|
74
|
+
"to": "you@example.com",
|
|
75
|
+
"min_interval_min": 15.0,
|
|
76
|
+
"max_per_hour": 5
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
`config.json` 不会提交到 Git。运行日志写在 `data/courser.log`;登录、浏览器连接或邮件发送出问题时,先看这里。
|
|
82
|
+
|
|
83
|
+
## Troubleshooting
|
|
84
|
+
|
|
85
|
+
`opencli doctor` 需要全部通过。若登录页填入账号密码后仍未跳转,先在同一个 Chrome 中手动完成一次登录,再执行 `uv run courser --once`。验证码和二次验证需要你本人处理,courser 不会代填。
|
|
86
|
+
|
|
87
|
+
## Development
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
uv run python tests/unit/test_opencli.py
|
|
91
|
+
uv run python tests/integration/test_round_runner.py
|
|
92
|
+
uv run python tests/tui/test_navigation.py
|
|
93
|
+
uv run python tests/tui/test_responsive.py
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
真实网站的端到端检查仍需手动执行:
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
uv run courser --once
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## License
|
|
103
|
+
|
|
104
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"first_run_done": false,
|
|
3
|
+
"interval_min": 8.0,
|
|
4
|
+
"interval_jitter": 0.3,
|
|
5
|
+
"page_delay_min": 0.8,
|
|
6
|
+
"page_delay_max": 2.0,
|
|
7
|
+
"session": "courser-watch",
|
|
8
|
+
"window": "background",
|
|
9
|
+
"force_relogin": true,
|
|
10
|
+
"credentials": {
|
|
11
|
+
"username": "",
|
|
12
|
+
"password": ""
|
|
13
|
+
},
|
|
14
|
+
"filters": {
|
|
15
|
+
"names": [],
|
|
16
|
+
"categories": [],
|
|
17
|
+
"depts": [
|
|
18
|
+
"英语语言文学系"
|
|
19
|
+
],
|
|
20
|
+
"match": "any"
|
|
21
|
+
},
|
|
22
|
+
"notify": {
|
|
23
|
+
"to": "you@example.com",
|
|
24
|
+
"gws_from": "",
|
|
25
|
+
"min_interval_min": 15.0,
|
|
26
|
+
"max_per_hour": 5
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""配置管理。
|
|
2
|
+
|
|
3
|
+
- config.json:TUI 可编辑的全部配置(轮询间隔、筛选条件、凭据、邮件、浏览器会话)
|
|
4
|
+
- 环境变量(.env):敏感信息(密码等)可选覆盖
|
|
5
|
+
所有配置集中在「设置」界面维护,代码里不散落魔法配置。
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, Optional
|
|
15
|
+
|
|
16
|
+
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
|
17
|
+
CONFIG_PATH = Path(os.environ.get("COURSER_CONFIG", str(PROJECT_ROOT / "config.json")))
|
|
18
|
+
DATA_DIR = PROJECT_ROOT / "data"
|
|
19
|
+
STATE_FILE = DATA_DIR / "notified.json"
|
|
20
|
+
SEND_LOG_FILE = DATA_DIR / "send_log.json" # 每小时发送预算的发送时间戳日志
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _env(key: str, default: str = "") -> str:
|
|
24
|
+
return os.environ.get(key, default)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class Credentials:
|
|
29
|
+
username: str = "" # 学号;留空则依赖密码管理器自动填充
|
|
30
|
+
password: str = "" # 密码;留空则依赖密码管理器自动填充
|
|
31
|
+
|
|
32
|
+
@classmethod
|
|
33
|
+
def from_dict(cls, d: Optional[dict]) -> "Credentials":
|
|
34
|
+
d = d or {}
|
|
35
|
+
return cls(
|
|
36
|
+
username=str(d.get("username", "") or _env("PKU_USERNAME", "")),
|
|
37
|
+
password=str(d.get("password", "") or _env("PKU_PASSWORD", "")),
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class Notify:
|
|
43
|
+
"""邮件通知(通过 gws = Google Workspace CLI 发送,需用户自行安装,
|
|
44
|
+
首次 `gws auth setup` 后 `gws auth login` 授权)。"""
|
|
45
|
+
|
|
46
|
+
to: str = "" # 收件人邮箱(提醒的目标地址,TUI 设置 / MAIL_TO 提供)
|
|
47
|
+
gws_from: str = "" # gws 发件账号(Gmail 地址,可选;默认取认证账号)
|
|
48
|
+
min_interval_min: float = 15.0 # 同一课程两次通知的最小间隔(分钟)
|
|
49
|
+
max_per_hour: int = 5 # 每小时最多发送的邮件封数(只限发信,不影响查询轮次)
|
|
50
|
+
|
|
51
|
+
@classmethod
|
|
52
|
+
def from_dict(cls, d: Optional[dict]) -> "Notify":
|
|
53
|
+
d = d or {}
|
|
54
|
+
return cls(
|
|
55
|
+
to=str(d.get("to", "") or _env("MAIL_TO", "")),
|
|
56
|
+
gws_from=str(d.get("gws_from", "") or _env("GWS_FROM", "")),
|
|
57
|
+
min_interval_min=float(d.get("min_interval_min", 15.0)),
|
|
58
|
+
max_per_hour=max(1, int(d.get("max_per_hour", 5))),
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def configured(self) -> bool:
|
|
63
|
+
return bool(self.to)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass
|
|
67
|
+
class Filters:
|
|
68
|
+
names: list[str] = field(default_factory=list) # 课程名(子串匹配)
|
|
69
|
+
categories: list[str] = field(default_factory=list) # 课程类别(子串匹配,如 通识核心课I类)
|
|
70
|
+
depts: list[str] = field(default_factory=list) # 开课单位(子串匹配,如 英语语言文学系)
|
|
71
|
+
match: str = "any" # any=任一命中 / all=全部命中
|
|
72
|
+
|
|
73
|
+
@classmethod
|
|
74
|
+
def from_dict(cls, d: Optional[dict]) -> "Filters":
|
|
75
|
+
d = d or {}
|
|
76
|
+
return cls(
|
|
77
|
+
names=list(d.get("names", [])),
|
|
78
|
+
categories=list(d.get("categories", [])),
|
|
79
|
+
depts=list(d.get("depts", [])),
|
|
80
|
+
match=str(d.get("match", "any")),
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def empty(self) -> bool:
|
|
85
|
+
return not (self.names or self.categories or self.depts)
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def active_groups(self) -> list[tuple[str, list[str]]]:
|
|
89
|
+
out = []
|
|
90
|
+
if self.names:
|
|
91
|
+
out.append(("课程名", self.names))
|
|
92
|
+
if self.categories:
|
|
93
|
+
out.append(("课程类别", self.categories))
|
|
94
|
+
if self.depts:
|
|
95
|
+
out.append(("开课院系", self.depts))
|
|
96
|
+
return out
|
|
97
|
+
|
|
98
|
+
def to_dict(self) -> dict:
|
|
99
|
+
return {"names": self.names, "categories": self.categories,
|
|
100
|
+
"depts": self.depts, "match": self.match}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@dataclass
|
|
104
|
+
class Config:
|
|
105
|
+
interval_min: float = 8.0 # 轮询基本间隔(分钟),实际带随机抖动
|
|
106
|
+
interval_jitter: float = 0.3 # 抖动比例(±30%)
|
|
107
|
+
page_delay_min: float = 0.8 # 相邻操作随机间隔下限(秒,人类速度)
|
|
108
|
+
page_delay_max: float = 2.0 # 相邻操作随机间隔上限(秒)
|
|
109
|
+
credentials: Credentials = field(default_factory=Credentials)
|
|
110
|
+
filters: Filters = field(default_factory=Filters)
|
|
111
|
+
notify: Notify = field(default_factory=Notify)
|
|
112
|
+
session: str = "courser-watch" # opencli 浏览器会话名
|
|
113
|
+
window: str = "background" # background=后台窗口,不抢焦点
|
|
114
|
+
force_relogin: bool = False # 默认复用有效会话;开启才每轮登出重登
|
|
115
|
+
first_run_done: bool = False # 初次启动向导是否已完成
|
|
116
|
+
cli_log: bool = False # CLI 模式(无 TUI)
|
|
117
|
+
|
|
118
|
+
@classmethod
|
|
119
|
+
def load(cls, path: Optional[Path] = None) -> "Config":
|
|
120
|
+
path = path or CONFIG_PATH
|
|
121
|
+
cfg = cls()
|
|
122
|
+
if path.exists():
|
|
123
|
+
try:
|
|
124
|
+
d = json.loads(path.read_text(encoding="utf-8"))
|
|
125
|
+
except Exception:
|
|
126
|
+
d = {}
|
|
127
|
+
cfg.interval_min = float(d.get("interval_min", cfg.interval_min))
|
|
128
|
+
cfg.interval_jitter = float(d.get("interval_jitter", cfg.interval_jitter))
|
|
129
|
+
cfg.page_delay_min = float(d.get("page_delay_min", cfg.page_delay_min))
|
|
130
|
+
cfg.page_delay_max = float(d.get("page_delay_max", cfg.page_delay_max))
|
|
131
|
+
cfg.session = str(d.get("session", cfg.session))
|
|
132
|
+
cfg.window = str(d.get("window", cfg.window))
|
|
133
|
+
cfg.force_relogin = bool(d.get("force_relogin", cfg.force_relogin))
|
|
134
|
+
cfg.first_run_done = bool(d.get("first_run_done", cfg.first_run_done))
|
|
135
|
+
cfg.credentials = Credentials.from_dict(d.get("credentials"))
|
|
136
|
+
cfg.filters = Filters.from_dict(d.get("filters"))
|
|
137
|
+
cfg.notify = Notify.from_dict(d.get("notify"))
|
|
138
|
+
return cfg
|
|
139
|
+
|
|
140
|
+
def save(self, path: Optional[Path] = None) -> None:
|
|
141
|
+
path = path or CONFIG_PATH
|
|
142
|
+
d = {
|
|
143
|
+
"interval_min": self.interval_min,
|
|
144
|
+
"interval_jitter": self.interval_jitter,
|
|
145
|
+
"page_delay_min": self.page_delay_min,
|
|
146
|
+
"page_delay_max": self.page_delay_max,
|
|
147
|
+
"session": self.session,
|
|
148
|
+
"window": self.window,
|
|
149
|
+
"force_relogin": self.force_relogin,
|
|
150
|
+
"first_run_done": self.first_run_done,
|
|
151
|
+
"credentials": {"username": self.credentials.username,
|
|
152
|
+
"password": self.credentials.password},
|
|
153
|
+
"filters": self.filters.to_dict(),
|
|
154
|
+
"notify": {"to": self.notify.to, "gws_from": self.notify.gws_from,
|
|
155
|
+
"min_interval_min": self.notify.min_interval_min,
|
|
156
|
+
"max_per_hour": self.notify.max_per_hour},
|
|
157
|
+
}
|
|
158
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
159
|
+
path.write_text(json.dumps(d, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
160
|
+
|
|
161
|
+
@property
|
|
162
|
+
def pacing(self) -> tuple[float, float]:
|
|
163
|
+
return (self.page_delay_min, self.page_delay_max)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def load_env_file(path: Optional[Path] = None) -> None:
|
|
167
|
+
"""加载项目根目录 .env(若存在),用于填充 PKU 凭据、GWS_FROM 等环境变量。"""
|
|
168
|
+
path = path or (PROJECT_ROOT / ".env")
|
|
169
|
+
if not path.exists():
|
|
170
|
+
return
|
|
171
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
172
|
+
line = line.strip()
|
|
173
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
174
|
+
continue
|
|
175
|
+
k, v = line.split("=", 1)
|
|
176
|
+
os.environ.setdefault(k.strip(), v.strip())
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""课程表规范 schema + 单元格格式化(唯一来源)。
|
|
2
|
+
|
|
3
|
+
课程表是所有输出渠道共享的**核心展示对象**,这里确立它的 canonical 定义:
|
|
4
|
+
|
|
5
|
+
Course
|
|
6
|
+
│
|
|
7
|
+
▼
|
|
8
|
+
COURSE_COLUMNS(列定义) + course_display(字段→显示文本)
|
|
9
|
+
│
|
|
10
|
+
┌───┴─────────────┐
|
|
11
|
+
▼ ▼
|
|
12
|
+
TUI 列布局 Email(HTML/纯文本)
|
|
13
|
+
终端宽度/折行/颜色 HTML/markdown 标签
|
|
14
|
+
|
|
15
|
+
各渠道**共享**:列 key / 标题 / 对齐语义 / 数据格式化(course_display)。
|
|
16
|
+
各渠道**不共享**:终端宽度、折行规则、ANSI 颜色、HTML/markdown 标签。
|
|
17
|
+
|
|
18
|
+
这样『限选/已选 拆分』『空余右对齐』只改这一处,TUI 主表 / TUI 筛选表 /
|
|
19
|
+
邮件 HTML / 邮件纯文本 自动同步,杜绝各模块手写字符串再分裂。
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
from typing import Optional
|
|
26
|
+
|
|
27
|
+
from .models import Course
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class Column:
|
|
32
|
+
"""一列课程表的规范定义。
|
|
33
|
+
|
|
34
|
+
- 结构列(页数/课程号/限选/已选/空余):width 固定;weight 为 0。
|
|
35
|
+
- 文本列(课程名/类别/开课单位/教师):width=None,按 weight 弹性分配。
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
key: str
|
|
39
|
+
title: str
|
|
40
|
+
align: str = "left"
|
|
41
|
+
width: Optional[int] = None # 固定宽(结构列/数值列)
|
|
42
|
+
min_width: int = 0 # 弹性文本列的底线
|
|
43
|
+
weight: float = 0.0 # 弹性文本列的宽度权重
|
|
44
|
+
preferred: Optional[int] = None # 弹性文本列的 cap
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def is_structural(self) -> bool:
|
|
48
|
+
"""结构列(固定宽,数值/ID),文本列(弹性)的对立面。"""
|
|
49
|
+
return self.width is not None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# 课程表规范顺序:页数 → 空余。TUI 实际渲染会把文本列塞进中间
|
|
53
|
+
# (页数/课程号, [文本列…], 限选/已选/空余);这是终端布局 adapter 的决定,
|
|
54
|
+
# 仍在 _columns() 里基于本 schema 推导,不另写第二份列定义。
|
|
55
|
+
COURSE_COLUMNS: list[Column] = [
|
|
56
|
+
Column("page", "页数", align="right", width=4),
|
|
57
|
+
Column("no", "课程号", align="left", width=10),
|
|
58
|
+
Column("name", "课程名", align="left", min_width=12, weight=3.0, preferred=30),
|
|
59
|
+
Column("cat", "课程类别", align="center", min_width=8, weight=1.5, preferred=20),
|
|
60
|
+
Column("dept", "开课单位", align="left", min_width=8, weight=1.5, preferred=20),
|
|
61
|
+
Column("teacher", "教师", align="left", min_width=10, weight=2.0, preferred=26),
|
|
62
|
+
Column("quota", "限选", align="right", width=5),
|
|
63
|
+
Column("selected", "已选", align="right", width=5),
|
|
64
|
+
Column("avail", "空余", align="right", width=5),
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
# key → Column 的便捷查找
|
|
68
|
+
_COLUMN_BY_KEY: dict[str, Column] = {c.key: c for c in COURSE_COLUMNS}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def column(key: str) -> Optional[Column]:
|
|
72
|
+
"""按 key 取列定义;未知 key 返回 None。"""
|
|
73
|
+
return _COLUMN_BY_KEY.get(key)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def course_display(c: Course, key: str) -> str:
|
|
77
|
+
"""课程某一列的**显示文本**(canonical 格式化)。
|
|
78
|
+
|
|
79
|
+
所有渠道都从这里取值,保证格式一致:
|
|
80
|
+
- 数值列:None/未知 → '—',有值 → str
|
|
81
|
+
- 文本列:空 → '—'
|
|
82
|
+
邮件额外用到的课程字段(班号/上课信息)也归入本表单。
|
|
83
|
+
"""
|
|
84
|
+
if key == "page":
|
|
85
|
+
return str(c.page) if c.page else "—"
|
|
86
|
+
if key == "no":
|
|
87
|
+
return c.course_no or "—"
|
|
88
|
+
if key == "name":
|
|
89
|
+
return c.name or "—"
|
|
90
|
+
if key == "cat":
|
|
91
|
+
return c.category or "—"
|
|
92
|
+
if key == "dept":
|
|
93
|
+
return c.dept or "—"
|
|
94
|
+
if key == "teacher":
|
|
95
|
+
return c.teacher or "—"
|
|
96
|
+
if key == "class_no":
|
|
97
|
+
return c.class_no or "—"
|
|
98
|
+
if key == "schedule":
|
|
99
|
+
return c.schedule or "—"
|
|
100
|
+
if key == "quota":
|
|
101
|
+
return str(c.quota) if c.quota is not None else "—"
|
|
102
|
+
if key == "selected":
|
|
103
|
+
return str(c.selected) if c.selected is not None else "—"
|
|
104
|
+
if key == "avail":
|
|
105
|
+
return str(c.avail) if c.avail >= 0 else "—"
|
|
106
|
+
return ""
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def course_order_key(c: Course) -> tuple[int]:
|
|
110
|
+
"""选课网顺序:页码升序;同页保持原始列表顺序。"""
|
|
111
|
+
return (c.page if c.page > 0 else 10**9,)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def ordered_courses(courses: list[Course]) -> list[Course]:
|
|
115
|
+
"""按选课网顺序返回课程副本;Python 稳定排序保留同页上下顺序。"""
|
|
116
|
+
return sorted(courses, key=course_order_key)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def seats_display(c: Course) -> str:
|
|
120
|
+
"""限选/已选 合并串(邮件纯文本等散文场景用);缺失回退原始串。"""
|
|
121
|
+
if c.quota is not None and c.selected is not None:
|
|
122
|
+
return f"{c.selected}/{c.quota}"
|
|
123
|
+
return (c.seats_raw or "").replace(" ", "") or "—"
|