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.
Files changed (41) hide show
  1. autoboya-0.1.0/LICENSE +21 -0
  2. autoboya-0.1.0/PKG-INFO +186 -0
  3. autoboya-0.1.0/README.md +155 -0
  4. autoboya-0.1.0/autoboya/__init__.py +1 -0
  5. autoboya-0.1.0/autoboya/__main__.py +4 -0
  6. autoboya-0.1.0/autoboya/auth.py +251 -0
  7. autoboya-0.1.0/autoboya/bykc.py +203 -0
  8. autoboya-0.1.0/autoboya/cache.py +75 -0
  9. autoboya-0.1.0/autoboya/cli.py +618 -0
  10. autoboya-0.1.0/autoboya/config.py +21 -0
  11. autoboya-0.1.0/autoboya/crypto.py +80 -0
  12. autoboya-0.1.0/autoboya/exceptions.py +72 -0
  13. autoboya-0.1.0/autoboya/http.py +16 -0
  14. autoboya-0.1.0/autoboya/logging.py +44 -0
  15. autoboya-0.1.0/autoboya/models.py +61 -0
  16. autoboya-0.1.0/autoboya/rules.py +91 -0
  17. autoboya-0.1.0/autoboya/scheduler.py +199 -0
  18. autoboya-0.1.0/autoboya/session.py +91 -0
  19. autoboya-0.1.0/autoboya/storage.py +100 -0
  20. autoboya-0.1.0/autoboya/webvpn.py +39 -0
  21. autoboya-0.1.0/autoboya.egg-info/PKG-INFO +186 -0
  22. autoboya-0.1.0/autoboya.egg-info/SOURCES.txt +39 -0
  23. autoboya-0.1.0/autoboya.egg-info/dependency_links.txt +1 -0
  24. autoboya-0.1.0/autoboya.egg-info/entry_points.txt +2 -0
  25. autoboya-0.1.0/autoboya.egg-info/requires.txt +8 -0
  26. autoboya-0.1.0/autoboya.egg-info/top_level.txt +1 -0
  27. autoboya-0.1.0/pyproject.toml +53 -0
  28. autoboya-0.1.0/setup.cfg +4 -0
  29. autoboya-0.1.0/tests/test_auth.py +84 -0
  30. autoboya-0.1.0/tests/test_bykc.py +158 -0
  31. autoboya-0.1.0/tests/test_cache_views.py +145 -0
  32. autoboya-0.1.0/tests/test_cli_errors.py +91 -0
  33. autoboya-0.1.0/tests/test_cli_smoke.py +73 -0
  34. autoboya-0.1.0/tests/test_cli_users.py +15 -0
  35. autoboya-0.1.0/tests/test_logging_redaction.py +10 -0
  36. autoboya-0.1.0/tests/test_manual_operations.py +123 -0
  37. autoboya-0.1.0/tests/test_rules.py +86 -0
  38. autoboya-0.1.0/tests/test_scheduler.py +212 -0
  39. autoboya-0.1.0/tests/test_session.py +103 -0
  40. autoboya-0.1.0/tests/test_storage.py +23 -0
  41. 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.
@@ -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.
@@ -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,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -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