autoboya 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.
- autoboya-0.1.0/LICENSE +21 -0
- autoboya-0.1.0/PKG-INFO +186 -0
- autoboya-0.1.0/README.md +155 -0
- autoboya-0.1.0/autoboya/__init__.py +1 -0
- autoboya-0.1.0/autoboya/__main__.py +4 -0
- autoboya-0.1.0/autoboya/auth.py +251 -0
- autoboya-0.1.0/autoboya/bykc.py +203 -0
- autoboya-0.1.0/autoboya/cache.py +75 -0
- autoboya-0.1.0/autoboya/cli.py +618 -0
- autoboya-0.1.0/autoboya/config.py +21 -0
- autoboya-0.1.0/autoboya/crypto.py +80 -0
- autoboya-0.1.0/autoboya/exceptions.py +72 -0
- autoboya-0.1.0/autoboya/http.py +16 -0
- autoboya-0.1.0/autoboya/logging.py +44 -0
- autoboya-0.1.0/autoboya/models.py +61 -0
- autoboya-0.1.0/autoboya/rules.py +91 -0
- autoboya-0.1.0/autoboya/scheduler.py +199 -0
- autoboya-0.1.0/autoboya/session.py +91 -0
- autoboya-0.1.0/autoboya/storage.py +100 -0
- autoboya-0.1.0/autoboya/webvpn.py +39 -0
- autoboya-0.1.0/autoboya.egg-info/PKG-INFO +186 -0
- autoboya-0.1.0/autoboya.egg-info/SOURCES.txt +39 -0
- autoboya-0.1.0/autoboya.egg-info/dependency_links.txt +1 -0
- autoboya-0.1.0/autoboya.egg-info/entry_points.txt +2 -0
- autoboya-0.1.0/autoboya.egg-info/requires.txt +8 -0
- autoboya-0.1.0/autoboya.egg-info/top_level.txt +1 -0
- autoboya-0.1.0/pyproject.toml +53 -0
- autoboya-0.1.0/setup.cfg +4 -0
- autoboya-0.1.0/tests/test_auth.py +84 -0
- autoboya-0.1.0/tests/test_bykc.py +158 -0
- autoboya-0.1.0/tests/test_cache_views.py +145 -0
- autoboya-0.1.0/tests/test_cli_errors.py +91 -0
- autoboya-0.1.0/tests/test_cli_smoke.py +73 -0
- autoboya-0.1.0/tests/test_cli_users.py +15 -0
- autoboya-0.1.0/tests/test_logging_redaction.py +10 -0
- autoboya-0.1.0/tests/test_manual_operations.py +123 -0
- autoboya-0.1.0/tests/test_rules.py +86 -0
- autoboya-0.1.0/tests/test_scheduler.py +212 -0
- autoboya-0.1.0/tests/test_session.py +103 -0
- autoboya-0.1.0/tests/test_storage.py +23 -0
- autoboya-0.1.0/tests/test_webvpn_crypto.py +27 -0
autoboya-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 DeNeRATe-cool
|
|
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.
|
autoboya-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: autoboya
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: BUAA Boya WebVPN CLI for course cache, autonomous-sign course automation, and check-in/out workflows
|
|
5
|
+
Author: DeNeRATe-cool
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/DeNeRATe-cool/AutoBoya
|
|
8
|
+
Project-URL: Repository, https://github.com/DeNeRATe-cool/AutoBoya
|
|
9
|
+
Project-URL: Issues, https://github.com/DeNeRATe-cool/AutoBoya/issues
|
|
10
|
+
Keywords: buaa,boya,webvpn,cli,automation
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Topic :: Utilities
|
|
20
|
+
Requires-Python: >=3.11
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Requires-Dist: cryptography>=42
|
|
24
|
+
Requires-Dist: httpx>=0.27
|
|
25
|
+
Requires-Dist: keyring>=25
|
|
26
|
+
Requires-Dist: rich>=13
|
|
27
|
+
Requires-Dist: typer>=0.12
|
|
28
|
+
Provides-Extra: dev
|
|
29
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
30
|
+
Dynamic: license-file
|
|
31
|
+
|
|
32
|
+
# AutoBoya
|
|
33
|
+
|
|
34
|
+
<p align="center">
|
|
35
|
+
<a href="https://pypi.org/project/autoboya/"><img alt="PyPI" src="https://img.shields.io/pypi/v/autoboya"></a>
|
|
36
|
+
<a href="https://pypi.org/project/autoboya/"><img alt="Python" src="https://img.shields.io/pypi/pyversions/autoboya"></a>
|
|
37
|
+
<a href="https://github.com/DeNeRATe-cool/AutoBoya/blob/main/LICENSE"><img alt="License" src="https://img.shields.io/github/license/DeNeRATe-cool/AutoBoya"></a>
|
|
38
|
+
<a href="https://github.com/DeNeRATe-cool/AutoBoya/stargazers"><img alt="Stars" src="https://img.shields.io/github/stars/DeNeRATe-cool/AutoBoya?style=flat"></a>
|
|
39
|
+
<a href="https://github.com/DeNeRATe-cool/AutoBoya/commits/main"><img alt="Last Commit" src="https://img.shields.io/github/last-commit/DeNeRATe-cool/AutoBoya/main"></a>
|
|
40
|
+
</p>
|
|
41
|
+
|
|
42
|
+
Python CLI for BUAA Boya course viewing and guarded automation through WebVPN.
|
|
43
|
+
|
|
44
|
+
AutoBoya can cache Boya course data, display selected courses and statistics,
|
|
45
|
+
preview autonomous-sign course candidates, and run a local scheduler for
|
|
46
|
+
automatic course selection, check-in, and check-out.
|
|
47
|
+
|
|
48
|
+
## Quickstart
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
# 1) Install from PyPI
|
|
52
|
+
pip install autoboya
|
|
53
|
+
|
|
54
|
+
# 2) Initialize local state under ~/.autoboya
|
|
55
|
+
autoboya init
|
|
56
|
+
|
|
57
|
+
# 3) Add a BUAA account. The password is stored in the system keyring when possible.
|
|
58
|
+
autoboya user add 223xxxxx --password-stdin
|
|
59
|
+
|
|
60
|
+
# 4) Login through WebVPN. Type the CAPTCHA shown by the CLI when prompted.
|
|
61
|
+
autoboya login 223xxxxx
|
|
62
|
+
|
|
63
|
+
# 5) Refresh course cache and inspect candidates.
|
|
64
|
+
autoboya courses refresh
|
|
65
|
+
autoboya courses list --only-selectable
|
|
66
|
+
autoboya courses auto-preview
|
|
67
|
+
|
|
68
|
+
# 6) Run one automation pass for debugging, or keep the scheduler running.
|
|
69
|
+
autoboya run-once
|
|
70
|
+
autoboya run
|
|
71
|
+
autoboya stop
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## PATH Notes
|
|
75
|
+
|
|
76
|
+
If `autoboya` is not found after installation, use Python's module entry point
|
|
77
|
+
first:
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
python -m autoboya --help
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Then add the user script directory to your shell PATH.
|
|
84
|
+
|
|
85
|
+
macOS / Linux:
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
python -m pip install --user autoboya
|
|
89
|
+
echo 'export PATH="$(python3 -m site --user-base)/bin:$PATH"' >> ~/.zprofile
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Windows PowerShell:
|
|
93
|
+
|
|
94
|
+
```powershell
|
|
95
|
+
py -m pip install --user autoboya
|
|
96
|
+
$d = py -c "import sysconfig; print(sysconfig.get_path('scripts','nt_user'))"; [Environment]::SetEnvironmentVariable("Path", [Environment]::GetEnvironmentVariable("Path","User") + ";" + $d, "User")
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Command Reference
|
|
100
|
+
|
|
101
|
+
General:
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
autoboya -h
|
|
105
|
+
autoboya --help
|
|
106
|
+
autoboya version
|
|
107
|
+
autoboya init
|
|
108
|
+
autoboya doctor
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Users and login:
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
autoboya user add <username> --password-stdin
|
|
115
|
+
autoboya user add <username> --unsafe-store-password
|
|
116
|
+
autoboya user list
|
|
117
|
+
autoboya user remove <username>
|
|
118
|
+
autoboya login <username>
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Courses and cache:
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
autoboya courses refresh
|
|
125
|
+
autoboya courses refresh --user <username>
|
|
126
|
+
autoboya courses list
|
|
127
|
+
autoboya courses list --only-selectable
|
|
128
|
+
autoboya courses list --json
|
|
129
|
+
autoboya courses show <course_id>
|
|
130
|
+
autoboya courses show <course_id> --json
|
|
131
|
+
autoboya courses auto-preview
|
|
132
|
+
autoboya courses auto-preview --json
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
`autoboya courses refresh` fetches the full paginated course list once and refreshes selected-course/statistics caches for every enabled user. Use `--user` to refresh selected-course/statistics caches for only one user.
|
|
136
|
+
|
|
137
|
+
Selected courses and statistics:
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
autoboya selected
|
|
141
|
+
autoboya selected --user <username>
|
|
142
|
+
autoboya selected --json
|
|
143
|
+
autoboya stats
|
|
144
|
+
autoboya stats --user <username>
|
|
145
|
+
autoboya stats --json
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Automation:
|
|
149
|
+
|
|
150
|
+
```bash
|
|
151
|
+
autoboya run
|
|
152
|
+
autoboya run-once
|
|
153
|
+
autoboya stop
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Manual operations:
|
|
157
|
+
|
|
158
|
+
```bash
|
|
159
|
+
autoboya select <course_id> --user <username> --yes
|
|
160
|
+
autoboya select <course_id> --all-users --yes
|
|
161
|
+
autoboya drop <course_id> --user <username> --yes
|
|
162
|
+
autoboya drop <course_id> --all-users --yes
|
|
163
|
+
autoboya sign <course_id> --user <username>
|
|
164
|
+
autoboya sign <course_id> --all-users
|
|
165
|
+
autoboya signout <course_id> --user <username>
|
|
166
|
+
autoboya signout <course_id> --all-users
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
`sign` and `signout` require the course to already be selected. Use `select` first, then sign during the configured sign window. `drop` accepts a course ID and refreshes the selected-course cache after a successful drop.
|
|
170
|
+
|
|
171
|
+
Diagnostics:
|
|
172
|
+
|
|
173
|
+
```bash
|
|
174
|
+
autoboya logs tail
|
|
175
|
+
autoboya logs tail --lines 200
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
Every command and command group accepts both `-h` and `--help`.
|
|
179
|
+
|
|
180
|
+
## Automation Policy
|
|
181
|
+
|
|
182
|
+
AutoBoya does not select every selectable course. The daemon only auto-selects cached courses that are currently selectable, whose sign method is `自主签到`, derived from a non-empty `courseSignConfig.signPointList`, and whose category is not `其他方面`. Courses with `常规签到`, no location sign config, or category `其他方面` are skipped. Use `autoboya courses auto-preview` to inspect the courses that would be selected before running the daemon.
|
|
183
|
+
|
|
184
|
+
CAPTCHA handling follows UBAA: the CLI fetches the SSO CAPTCHA image and asks the operator to type the code. It does not OCR or bypass CAPTCHA.
|
|
185
|
+
|
|
186
|
+
State is stored under `~/.autoboya`: users, settings, cache, logs, run files, CAPTCHA images, and session metadata.
|
autoboya-0.1.0/README.md
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# AutoBoya
|
|
2
|
+
|
|
3
|
+
<p align="center">
|
|
4
|
+
<a href="https://pypi.org/project/autoboya/"><img alt="PyPI" src="https://img.shields.io/pypi/v/autoboya"></a>
|
|
5
|
+
<a href="https://pypi.org/project/autoboya/"><img alt="Python" src="https://img.shields.io/pypi/pyversions/autoboya"></a>
|
|
6
|
+
<a href="https://github.com/DeNeRATe-cool/AutoBoya/blob/main/LICENSE"><img alt="License" src="https://img.shields.io/github/license/DeNeRATe-cool/AutoBoya"></a>
|
|
7
|
+
<a href="https://github.com/DeNeRATe-cool/AutoBoya/stargazers"><img alt="Stars" src="https://img.shields.io/github/stars/DeNeRATe-cool/AutoBoya?style=flat"></a>
|
|
8
|
+
<a href="https://github.com/DeNeRATe-cool/AutoBoya/commits/main"><img alt="Last Commit" src="https://img.shields.io/github/last-commit/DeNeRATe-cool/AutoBoya/main"></a>
|
|
9
|
+
</p>
|
|
10
|
+
|
|
11
|
+
Python CLI for BUAA Boya course viewing and guarded automation through WebVPN.
|
|
12
|
+
|
|
13
|
+
AutoBoya can cache Boya course data, display selected courses and statistics,
|
|
14
|
+
preview autonomous-sign course candidates, and run a local scheduler for
|
|
15
|
+
automatic course selection, check-in, and check-out.
|
|
16
|
+
|
|
17
|
+
## Quickstart
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
# 1) Install from PyPI
|
|
21
|
+
pip install autoboya
|
|
22
|
+
|
|
23
|
+
# 2) Initialize local state under ~/.autoboya
|
|
24
|
+
autoboya init
|
|
25
|
+
|
|
26
|
+
# 3) Add a BUAA account. The password is stored in the system keyring when possible.
|
|
27
|
+
autoboya user add 223xxxxx --password-stdin
|
|
28
|
+
|
|
29
|
+
# 4) Login through WebVPN. Type the CAPTCHA shown by the CLI when prompted.
|
|
30
|
+
autoboya login 223xxxxx
|
|
31
|
+
|
|
32
|
+
# 5) Refresh course cache and inspect candidates.
|
|
33
|
+
autoboya courses refresh
|
|
34
|
+
autoboya courses list --only-selectable
|
|
35
|
+
autoboya courses auto-preview
|
|
36
|
+
|
|
37
|
+
# 6) Run one automation pass for debugging, or keep the scheduler running.
|
|
38
|
+
autoboya run-once
|
|
39
|
+
autoboya run
|
|
40
|
+
autoboya stop
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## PATH Notes
|
|
44
|
+
|
|
45
|
+
If `autoboya` is not found after installation, use Python's module entry point
|
|
46
|
+
first:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
python -m autoboya --help
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Then add the user script directory to your shell PATH.
|
|
53
|
+
|
|
54
|
+
macOS / Linux:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
python -m pip install --user autoboya
|
|
58
|
+
echo 'export PATH="$(python3 -m site --user-base)/bin:$PATH"' >> ~/.zprofile
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Windows PowerShell:
|
|
62
|
+
|
|
63
|
+
```powershell
|
|
64
|
+
py -m pip install --user autoboya
|
|
65
|
+
$d = py -c "import sysconfig; print(sysconfig.get_path('scripts','nt_user'))"; [Environment]::SetEnvironmentVariable("Path", [Environment]::GetEnvironmentVariable("Path","User") + ";" + $d, "User")
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Command Reference
|
|
69
|
+
|
|
70
|
+
General:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
autoboya -h
|
|
74
|
+
autoboya --help
|
|
75
|
+
autoboya version
|
|
76
|
+
autoboya init
|
|
77
|
+
autoboya doctor
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Users and login:
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
autoboya user add <username> --password-stdin
|
|
84
|
+
autoboya user add <username> --unsafe-store-password
|
|
85
|
+
autoboya user list
|
|
86
|
+
autoboya user remove <username>
|
|
87
|
+
autoboya login <username>
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Courses and cache:
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
autoboya courses refresh
|
|
94
|
+
autoboya courses refresh --user <username>
|
|
95
|
+
autoboya courses list
|
|
96
|
+
autoboya courses list --only-selectable
|
|
97
|
+
autoboya courses list --json
|
|
98
|
+
autoboya courses show <course_id>
|
|
99
|
+
autoboya courses show <course_id> --json
|
|
100
|
+
autoboya courses auto-preview
|
|
101
|
+
autoboya courses auto-preview --json
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
`autoboya courses refresh` fetches the full paginated course list once and refreshes selected-course/statistics caches for every enabled user. Use `--user` to refresh selected-course/statistics caches for only one user.
|
|
105
|
+
|
|
106
|
+
Selected courses and statistics:
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
autoboya selected
|
|
110
|
+
autoboya selected --user <username>
|
|
111
|
+
autoboya selected --json
|
|
112
|
+
autoboya stats
|
|
113
|
+
autoboya stats --user <username>
|
|
114
|
+
autoboya stats --json
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Automation:
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
autoboya run
|
|
121
|
+
autoboya run-once
|
|
122
|
+
autoboya stop
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Manual operations:
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
autoboya select <course_id> --user <username> --yes
|
|
129
|
+
autoboya select <course_id> --all-users --yes
|
|
130
|
+
autoboya drop <course_id> --user <username> --yes
|
|
131
|
+
autoboya drop <course_id> --all-users --yes
|
|
132
|
+
autoboya sign <course_id> --user <username>
|
|
133
|
+
autoboya sign <course_id> --all-users
|
|
134
|
+
autoboya signout <course_id> --user <username>
|
|
135
|
+
autoboya signout <course_id> --all-users
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
`sign` and `signout` require the course to already be selected. Use `select` first, then sign during the configured sign window. `drop` accepts a course ID and refreshes the selected-course cache after a successful drop.
|
|
139
|
+
|
|
140
|
+
Diagnostics:
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
autoboya logs tail
|
|
144
|
+
autoboya logs tail --lines 200
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Every command and command group accepts both `-h` and `--help`.
|
|
148
|
+
|
|
149
|
+
## Automation Policy
|
|
150
|
+
|
|
151
|
+
AutoBoya does not select every selectable course. The daemon only auto-selects cached courses that are currently selectable, whose sign method is `自主签到`, derived from a non-empty `courseSignConfig.signPointList`, and whose category is not `其他方面`. Courses with `常规签到`, no location sign config, or category `其他方面` are skipped. Use `autoboya courses auto-preview` to inspect the courses that would be selected before running the daemon.
|
|
152
|
+
|
|
153
|
+
CAPTCHA handling follows UBAA: the CLI fetches the SSO CAPTCHA image and asks the operator to type the code. It does not OCR or bypass CAPTCHA.
|
|
154
|
+
|
|
155
|
+
State is stored under `~/.autoboya`: users, settings, cache, logs, run files, CAPTCHA images, and session metadata.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import html.parser
|
|
4
|
+
import re
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from urllib.parse import parse_qs, urljoin, urlparse
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from .exceptions import CaptchaRequired, LoginError
|
|
12
|
+
from .storage import AutoBoyaStore
|
|
13
|
+
from .webvpn import to_webvpn_url
|
|
14
|
+
|
|
15
|
+
SSO_LOGIN = "https://sso.buaa.edu.cn/login"
|
|
16
|
+
SSO_CAPTCHA = "https://sso.buaa.edu.cn/captcha"
|
|
17
|
+
UC_ACTIVATE = "https://uc.buaa.edu.cn/api/login?target=https%3A%2F%2Fuc.buaa.edu.cn%2F%23%2Fuser%2Flogin"
|
|
18
|
+
BYKC_CAS = "https://bykc.buaa.edu.cn/sscv/cas/login"
|
|
19
|
+
BYKC_CAS_EMPTY_TOKEN = "https://bykc.buaa.edu.cn/cas-login?token="
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class CaptchaChallenge:
|
|
24
|
+
captcha_id: str
|
|
25
|
+
captcha_type: str
|
|
26
|
+
image_path: Path
|
|
27
|
+
execution: str
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class AuthSession:
|
|
32
|
+
username: str
|
|
33
|
+
bykc_token: str
|
|
34
|
+
cookies: list[dict[str, object]]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class _FormParser(html.parser.HTMLParser):
|
|
38
|
+
def __init__(self) -> None:
|
|
39
|
+
super().__init__()
|
|
40
|
+
self.forms: list[dict[str, object]] = []
|
|
41
|
+
self.current: dict[str, object] | None = None
|
|
42
|
+
|
|
43
|
+
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
|
44
|
+
attrs_dict = {key: (value or "") for key, value in attrs}
|
|
45
|
+
if tag.lower() == "form":
|
|
46
|
+
self.current = {"attrs": attrs_dict, "inputs": []}
|
|
47
|
+
self.forms.append(self.current)
|
|
48
|
+
elif tag.lower() == "input" and self.current is not None:
|
|
49
|
+
self.current["inputs"].append(attrs_dict) # type: ignore[index,union-attr]
|
|
50
|
+
|
|
51
|
+
def handle_endtag(self, tag: str) -> None:
|
|
52
|
+
if tag.lower() == "form":
|
|
53
|
+
self.current = None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class AuthClient:
|
|
57
|
+
def __init__(
|
|
58
|
+
self,
|
|
59
|
+
store: AutoBoyaStore,
|
|
60
|
+
http_client: httpx.Client | None = None,
|
|
61
|
+
use_vpn: bool = True,
|
|
62
|
+
) -> None:
|
|
63
|
+
self.store = store
|
|
64
|
+
self.client = http_client or httpx.Client(timeout=25, follow_redirects=False)
|
|
65
|
+
self.use_vpn = use_vpn
|
|
66
|
+
self._last_login_html: str | None = None
|
|
67
|
+
|
|
68
|
+
def upstream(self, url: str) -> str:
|
|
69
|
+
return to_webvpn_url(url) if self.use_vpn else url
|
|
70
|
+
|
|
71
|
+
def preflight_login(self) -> str:
|
|
72
|
+
response = self.client.get(self.upstream(SSO_LOGIN))
|
|
73
|
+
if response.status_code >= 400:
|
|
74
|
+
raise LoginError(f"SSO login page returned HTTP {response.status_code}")
|
|
75
|
+
html = response.text
|
|
76
|
+
self._last_login_html = html
|
|
77
|
+
captcha = detect_captcha(html)
|
|
78
|
+
if captcha:
|
|
79
|
+
image = self.fetch_captcha(captcha[1])
|
|
80
|
+
raise CaptchaRequired(
|
|
81
|
+
CaptchaChallenge(
|
|
82
|
+
captcha_id=captcha[1],
|
|
83
|
+
captcha_type=captcha[0],
|
|
84
|
+
image_path=image,
|
|
85
|
+
execution=extract_execution(html),
|
|
86
|
+
)
|
|
87
|
+
)
|
|
88
|
+
return extract_execution(html)
|
|
89
|
+
|
|
90
|
+
def fetch_captcha(self, captcha_id: str) -> Path:
|
|
91
|
+
response = self.client.get(self.upstream(f"{SSO_CAPTCHA}?captchaId={captcha_id}"))
|
|
92
|
+
response.raise_for_status()
|
|
93
|
+
suffix = ".png" if "png" in response.headers.get("content-type", "") else ".jpg"
|
|
94
|
+
path = self.store.path(f"captcha/{captcha_id}{suffix}")
|
|
95
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
96
|
+
path.write_bytes(response.content)
|
|
97
|
+
return path
|
|
98
|
+
|
|
99
|
+
def login(self, username: str, password: str, captcha: str | None = None) -> AuthSession:
|
|
100
|
+
html = self._last_login_html
|
|
101
|
+
if html is None:
|
|
102
|
+
try:
|
|
103
|
+
self.preflight_login()
|
|
104
|
+
html = self._last_login_html
|
|
105
|
+
except CaptchaRequired as exc:
|
|
106
|
+
if not captcha:
|
|
107
|
+
raise
|
|
108
|
+
html = self._last_login_html
|
|
109
|
+
if html is None:
|
|
110
|
+
raise LoginError("Unable to load SSO login form")
|
|
111
|
+
params = build_login_params(html, username, password, captcha)
|
|
112
|
+
response = self.client.post(
|
|
113
|
+
self.upstream(SSO_LOGIN),
|
|
114
|
+
data=params,
|
|
115
|
+
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
116
|
+
)
|
|
117
|
+
final = self.follow_redirects_and_password_expiry(response)
|
|
118
|
+
error = find_login_error(final.text)
|
|
119
|
+
if final.status_code >= 400 or error:
|
|
120
|
+
raise LoginError(error or f"SSO login failed with HTTP {final.status_code}")
|
|
121
|
+
self.activate_uc()
|
|
122
|
+
token = self.acquire_bykc_token()
|
|
123
|
+
if not token:
|
|
124
|
+
raise LoginError("Boya token was not returned after SSO login")
|
|
125
|
+
return AuthSession(username=username, bykc_token=token, cookies=serialize_cookies(self.client))
|
|
126
|
+
|
|
127
|
+
def follow_redirects_and_password_expiry(self, response: httpx.Response) -> httpx.Response:
|
|
128
|
+
current = response
|
|
129
|
+
ignored = False
|
|
130
|
+
while True:
|
|
131
|
+
while 300 <= current.status_code <= 399 and current.headers.get("location"):
|
|
132
|
+
current = self.client.get(urljoin(str(current.url), current.headers["location"]))
|
|
133
|
+
if ("continueForm" in current.text or "ignoreAndContinue" in current.text) and not ignored:
|
|
134
|
+
execution = extract_execution(current.text)
|
|
135
|
+
current = self.client.post(
|
|
136
|
+
str(current.url).split("?", 1)[0],
|
|
137
|
+
data={"execution": execution, "_eventId": "ignoreAndContinue"},
|
|
138
|
+
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
139
|
+
)
|
|
140
|
+
ignored = True
|
|
141
|
+
continue
|
|
142
|
+
return current
|
|
143
|
+
|
|
144
|
+
def activate_uc(self) -> None:
|
|
145
|
+
self.client.get(self.upstream(UC_ACTIVATE))
|
|
146
|
+
|
|
147
|
+
def acquire_bykc_token(self) -> str | None:
|
|
148
|
+
token = self._follow_for_token(self.upstream(BYKC_CAS))
|
|
149
|
+
if token:
|
|
150
|
+
return token
|
|
151
|
+
return self._follow_for_token(self.upstream(BYKC_CAS_EMPTY_TOKEN))
|
|
152
|
+
|
|
153
|
+
def _follow_for_token(self, url: str, max_redirects: int = 10) -> str | None:
|
|
154
|
+
current_url = url
|
|
155
|
+
for _ in range(max_redirects + 1):
|
|
156
|
+
response = self.client.get(current_url)
|
|
157
|
+
token = extract_token(str(response.url)) or extract_token(response.headers.get("location", ""))
|
|
158
|
+
if token:
|
|
159
|
+
return token
|
|
160
|
+
location = response.headers.get("location")
|
|
161
|
+
if not (300 <= response.status_code <= 399 and location):
|
|
162
|
+
return None
|
|
163
|
+
current_url = urljoin(str(response.url), location)
|
|
164
|
+
return None
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def detect_captcha(html: str) -> tuple[str, str] | None:
|
|
168
|
+
match = re.search(
|
|
169
|
+
r"config\.captcha\s*=\s*\{\s*type:\s*['\"]([^'\"]+)['\"],\s*id:\s*['\"]([^'\"]+)['\"]",
|
|
170
|
+
html,
|
|
171
|
+
)
|
|
172
|
+
return (match.group(1), match.group(2)) if match else None
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def extract_execution(html: str) -> str:
|
|
176
|
+
match = re.search(r'name=["\']execution["\'][^>]*value=["\']([^"\']+)["\']', html)
|
|
177
|
+
if not match:
|
|
178
|
+
match = re.search(r'value=["\']([^"\']+)["\'][^>]*name=["\']execution["\']', html)
|
|
179
|
+
return match.group(1) if match else ""
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def build_login_params(html: str, username: str, password: str, captcha: str | None = None) -> dict[str, str]:
|
|
183
|
+
parser = _FormParser()
|
|
184
|
+
parser.feed(html)
|
|
185
|
+
form = parser.forms[0] if parser.forms else {"inputs": []}
|
|
186
|
+
params: dict[str, str] = {}
|
|
187
|
+
present: set[str] = set()
|
|
188
|
+
for attrs in form.get("inputs", []): # type: ignore[union-attr]
|
|
189
|
+
name = (attrs.get("name") or "").strip()
|
|
190
|
+
if not name:
|
|
191
|
+
continue
|
|
192
|
+
input_type = (attrs.get("type") or "").strip().lower()
|
|
193
|
+
value = attrs.get("value") or ""
|
|
194
|
+
if name in {"username", "password"}:
|
|
195
|
+
present.add(name)
|
|
196
|
+
continue
|
|
197
|
+
if input_type in {"submit", "button", "image"}:
|
|
198
|
+
continue
|
|
199
|
+
if value:
|
|
200
|
+
params[name] = value
|
|
201
|
+
present.add(name)
|
|
202
|
+
params["username"] = username
|
|
203
|
+
params["password"] = password
|
|
204
|
+
params["submit"] = "登录"
|
|
205
|
+
params.setdefault("type", "username_password")
|
|
206
|
+
params.setdefault("execution", extract_execution(html))
|
|
207
|
+
params.setdefault("_eventId", "submit")
|
|
208
|
+
if captcha:
|
|
209
|
+
params["captcha"] = captcha
|
|
210
|
+
params["captchaResponse"] = captcha
|
|
211
|
+
return params
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def find_login_error(html: str) -> str | None:
|
|
215
|
+
for pattern in [
|
|
216
|
+
r"Invalid credentials\.",
|
|
217
|
+
r"Access Denied[^<\"]*",
|
|
218
|
+
r"<div class=\"tip-text\">([^<]+)</div>",
|
|
219
|
+
r"<p[^>]*>([^<]*(?:错误|密码|验证码|失败)[^<]*)</p>",
|
|
220
|
+
]:
|
|
221
|
+
match = re.search(pattern, html, flags=re.IGNORECASE)
|
|
222
|
+
if match:
|
|
223
|
+
return re.sub(r"\s+", " ", match.group(1) if match.groups() else match.group(0)).strip()
|
|
224
|
+
return None
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def extract_token(url: str) -> str | None:
|
|
228
|
+
if not url:
|
|
229
|
+
return None
|
|
230
|
+
parsed = urlparse(url)
|
|
231
|
+
token = parse_qs(parsed.query).get("token", [None])[0]
|
|
232
|
+
if token:
|
|
233
|
+
return token
|
|
234
|
+
match = re.search(r"[?&]token=([^&\s]+)", url)
|
|
235
|
+
return match.group(1) if match else None
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def serialize_cookies(client: httpx.Client) -> list[dict[str, object]]:
|
|
239
|
+
cookies: list[dict[str, object]] = []
|
|
240
|
+
for cookie in client.cookies.jar:
|
|
241
|
+
cookies.append(
|
|
242
|
+
{
|
|
243
|
+
"name": cookie.name,
|
|
244
|
+
"value": cookie.value,
|
|
245
|
+
"domain": cookie.domain,
|
|
246
|
+
"path": cookie.path,
|
|
247
|
+
"secure": bool(cookie.secure),
|
|
248
|
+
"expires": cookie.expires,
|
|
249
|
+
}
|
|
250
|
+
)
|
|
251
|
+
return cookies
|