buaalogin-cli 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1 @@
1
+ """北航校园网自动登录工具"""
@@ -0,0 +1,12 @@
1
+ """Allow running as `python -m buaalogin_cli`."""
2
+
3
+ from buaalogin_cli.cli import app
4
+
5
+
6
+ def main() -> None:
7
+ """程序主入口函数。"""
8
+ app()
9
+
10
+
11
+ if __name__ == "__main__":
12
+ main()
buaalogin_cli/cli.py ADDED
@@ -0,0 +1,227 @@
1
+ """BUAA 校园网自动登录 CLI 工具"""
2
+
3
+ import typer
4
+
5
+ from . import service
6
+ from .config import config
7
+ from .constants import CONFIG_FILE, LOG_FILE
8
+ from .log import setup_console
9
+
10
+ app = typer.Typer(
11
+ help="BUAA 校园网自动登录工具",
12
+ add_completion=False,
13
+ context_settings={"help_option_names": ["-h", "--help"]},
14
+ )
15
+
16
+
17
+ @app.callback(invoke_without_command=True)
18
+ def callback(ctx: typer.Context):
19
+ """BUAA 校园网登录工具。
20
+
21
+ 在任何子命令执行前调用,加载配置文件并设置默认参数值。
22
+ 如果未指定子命令则显示帮助信息。
23
+ """
24
+ # 这些值会覆盖子命令中的参数默认值
25
+ file_config = config.to_dict()
26
+
27
+ # 子命令会继承配置文件的值(如果命令行未显式指定参数)
28
+ ctx.default_map = {
29
+ "login": file_config,
30
+ "run": file_config,
31
+ }
32
+
33
+ # 若未指定子命令,显示帮助信息后退出
34
+ if ctx.invoked_subcommand is None:
35
+ typer.echo(ctx.get_help())
36
+ raise typer.Exit()
37
+
38
+
39
+ @app.command("login")
40
+ def login_cmd(
41
+ username: str | None = typer.Option(
42
+ None,
43
+ "--user",
44
+ "-u",
45
+ envvar="BUAA_USERNAME",
46
+ metavar="学号",
47
+ help="校园网账号",
48
+ show_default=False,
49
+ ),
50
+ password: str | None = typer.Option(
51
+ None,
52
+ "--pass",
53
+ "-p",
54
+ envvar="BUAA_PASSWORD",
55
+ metavar="密码",
56
+ help="校园网密码",
57
+ show_default=False,
58
+ ),
59
+ headless: bool = typer.Option(
60
+ True,
61
+ "--headless/--headed",
62
+ help="是否使用无头模式运行浏览器",
63
+ ),
64
+ verbose: bool = typer.Option(False, "--verbose", "-v", help="显示详细调试信息"),
65
+ ):
66
+ """执行单次登录。"""
67
+ setup_console(verbose=verbose)
68
+ _do_login_cmd(username, password, headless)
69
+
70
+
71
+ @app.command("run")
72
+ def run_cmd(
73
+ username: str | None = typer.Option(
74
+ None,
75
+ "--user",
76
+ "-u",
77
+ envvar="BUAA_USERNAME",
78
+ metavar="学号",
79
+ help="校园网账号",
80
+ show_default=False,
81
+ ),
82
+ password: str | None = typer.Option(
83
+ None,
84
+ "--pass",
85
+ "-p",
86
+ envvar="BUAA_PASSWORD",
87
+ metavar="密码",
88
+ help="校园网密码",
89
+ show_default=False,
90
+ ),
91
+ interval: int = typer.Option(
92
+ 5,
93
+ "--interval",
94
+ "-i",
95
+ envvar="BUAA_CHECK_INTERVAL",
96
+ metavar="分钟",
97
+ min=1,
98
+ help="检测间隔",
99
+ ),
100
+ headless: bool = typer.Option(
101
+ True,
102
+ "--headless/--headed",
103
+ help="是否使用无头模式运行浏览器",
104
+ ),
105
+ verbose: bool = typer.Option(False, "--verbose", "-v", help="显示详细调试信息"),
106
+ ):
107
+ """持续保持在线,定期检测并自动重连。"""
108
+ setup_console(verbose=verbose)
109
+
110
+ passwd = password
111
+
112
+ if username is None or passwd is None:
113
+ typer.secho("❌ 缺少账号或密码", fg=typer.colors.RED)
114
+ typer.echo("\n请通过以下方式之一提供凭据:")
115
+ typer.echo(" 1. 运行 `buaalogin config` 配置")
116
+ typer.echo(" 2. 使用命令行参数: --user <账号> --pass <密码>")
117
+ typer.echo(" 3. 设置环境变量: BUAA_USERNAME, BUAA_PASSWORD")
118
+ raise typer.Exit(1)
119
+
120
+ service.keep_alive(username, passwd, interval, headless=headless)
121
+
122
+
123
+ @app.command("config")
124
+ def config_cmd(
125
+ username: str | None = typer.Option(
126
+ None, "--user", "-u", metavar="学号", help="校园网账号"
127
+ ),
128
+ password: str | None = typer.Option(
129
+ None, "--pass", "-p", metavar="密码", help="校园网密码"
130
+ ),
131
+ interval: int | None = typer.Option(
132
+ None, "--interval", "-i", metavar="分钟", min=1, help="保活检测间隔"
133
+ ),
134
+ show: bool = typer.Option(
135
+ False, "--show", "-s", is_flag=True, help="仅显示当前配置", show_default=True
136
+ ),
137
+ ):
138
+ """配置账户信息。"""
139
+ if show:
140
+ typer.secho(f"配置文件: {CONFIG_FILE}", fg=typer.colors.CYAN)
141
+ saved = config.to_dict()
142
+ if saved:
143
+ for key, value in saved.items():
144
+ typer.echo(f" {key} = {value}")
145
+ else:
146
+ typer.echo(" (尚未配置)")
147
+ return
148
+
149
+ # 交互式输入
150
+ if not username:
151
+ username = typer.prompt("请输入 BUAA 学号")
152
+ while not username:
153
+ typer.secho("学号不能为空", fg=typer.colors.RED)
154
+ username = typer.prompt("请输入 BUAA 学号")
155
+ if not password:
156
+ password = typer.prompt("请输入密码")
157
+ while not password:
158
+ typer.secho("密码不能为空", fg=typer.colors.RED)
159
+ password = typer.prompt("请输入密码")
160
+
161
+ # 更新配置并保存
162
+ config.username = username
163
+ config.password = password
164
+ if interval is not None:
165
+ config.interval = interval
166
+ config.save_to_json(CONFIG_FILE)
167
+ typer.secho("✅ 配置已保存!", fg=typer.colors.GREEN)
168
+ typer.echo(f" 位置: {CONFIG_FILE}")
169
+
170
+
171
+ @app.command("status")
172
+ def status_cmd():
173
+ """检查当前网络连接状态。退出码:已登录=0,未登录=1。"""
174
+ if service.get_status() == service.NetworkStatus.LOGGED_IN:
175
+ typer.secho("✅ 网络正常", fg=typer.colors.GREEN)
176
+ raise typer.Exit(0)
177
+ else:
178
+ typer.secho("❌ 未登录或无法访问外网", fg=typer.colors.RED)
179
+ raise typer.Exit(1)
180
+
181
+
182
+ @app.command("info")
183
+ def info_cmd():
184
+ """显示配置文件和日志文件的存储位置。"""
185
+ config_file = CONFIG_FILE
186
+
187
+ typer.echo("配置文件:")
188
+ typer.echo(f" {config_file}")
189
+ if config_file.exists():
190
+ typer.secho(" ✅ 已存在", fg=typer.colors.GREEN)
191
+ else:
192
+ typer.secho(" ⚠️ 未配置", fg=typer.colors.YELLOW)
193
+
194
+ typer.echo()
195
+ typer.echo("日志文件:")
196
+ typer.echo(f" {LOG_FILE}")
197
+ if LOG_FILE.exists():
198
+ size = LOG_FILE.stat().st_size
199
+ typer.secho(f" ✅ 文件大小: {size / 1024:.1f} KB", fg=typer.colors.GREEN)
200
+ else:
201
+ typer.secho(" 📝 尚未生成", fg=typer.colors.BLUE)
202
+
203
+
204
+ def _do_login_cmd(
205
+ cli_username: str | None,
206
+ cli_pass: str | None,
207
+ headless: bool = True,
208
+ ):
209
+ """执行单次登录的 CLI 逻辑(含错误提示)。"""
210
+ if cli_username is None or cli_pass is None:
211
+ typer.secho("❌ 缺少账号或密码", fg=typer.colors.RED)
212
+ typer.echo("\n请通过以下方式之一提供凭据:")
213
+ typer.echo(" 1. 运行 `buaalogin config` 配置")
214
+ typer.echo(" 2. 使用命令行参数: --user <账号> --pass <密码>")
215
+ typer.echo(" 3. 设置环境变量: BUAA_USERNAME, BUAA_PASSWORD")
216
+ raise typer.Exit(1)
217
+
218
+ try:
219
+ service.login(cli_username, cli_pass, headless=headless)
220
+ typer.secho("✅ 登录成功", fg=typer.colors.GREEN)
221
+ except service.LoginError as e:
222
+ typer.secho(f"❌ 登录失败: {e}", fg=typer.colors.RED)
223
+ raise typer.Exit(1) from None
224
+
225
+
226
+ if __name__ == "__main__":
227
+ app()
@@ -0,0 +1,47 @@
1
+ """配置管理模块:配置加载/保存、优先级合并"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from msgspec import UNSET, Struct, UnsetType, structs
9
+ from msgspec import json as msgjson
10
+
11
+ from .constants import CONFIG_FILE
12
+
13
+
14
+ class Config(Struct, omit_defaults=True):
15
+ """用户配置,保存到配置文件。
16
+
17
+ Attributes:
18
+ username: 校园网账号。
19
+ password: 校园网密码。
20
+ interval: 检测间隔(分钟)。
21
+ """
22
+
23
+ username: str | UnsetType = UNSET
24
+ password: str | UnsetType = UNSET
25
+ interval: int | UnsetType = UNSET
26
+
27
+ @classmethod
28
+ def load_from_json(cls, file_path: str | Path) -> Config:
29
+ """从 JSON 文件加载配置,文件不存在时返回空配置。"""
30
+ try:
31
+ bytes = Path(file_path).read_bytes()
32
+ return msgjson.decode(bytes, type=Config)
33
+ except FileNotFoundError:
34
+ return cls()
35
+
36
+ def save_to_json(self, file_path: Path | str) -> int:
37
+ """保存当前配置到 JSON 文件。"""
38
+ bytes = msgjson.encode(self) + b"\n"
39
+ return Path(file_path).write_bytes(bytes)
40
+
41
+ def to_dict(self) -> dict[str, Any]:
42
+ """导出为字典(过滤 UNSET 值)。"""
43
+ return {k: v for k, v in structs.asdict(self).items() if v is not UNSET}
44
+
45
+
46
+ # 全局配置实例
47
+ config = Config.load_from_json(CONFIG_FILE)
@@ -0,0 +1,15 @@
1
+ """常量模块:路径、URL"""
2
+
3
+ from pathlib import Path
4
+
5
+ from platformdirs import user_config_dir, user_log_dir
6
+
7
+ APP_NAME = "buaalogin-cli"
8
+
9
+ # 文件路径
10
+ CONFIG_FILE = Path(user_config_dir(APP_NAME, ensure_exists=True)) / "config.json"
11
+ LOG_FILE = Path(user_log_dir(APP_NAME, ensure_exists=True)) / f"{APP_NAME}.log"
12
+
13
+ # URL
14
+ GATEWAY_URL = "https://gw.buaa.edu.cn"
15
+ LOGIN_URL = GATEWAY_URL
buaalogin_cli/log.py ADDED
@@ -0,0 +1,67 @@
1
+ """日志配置模块:集中管理日志设置,供所有模块导入使用"""
2
+
3
+ import sys
4
+
5
+ from loguru import logger
6
+
7
+ from .constants import LOG_FILE
8
+
9
+ # 移除 loguru 默认的 stderr handler,稍后重新配置
10
+ logger.remove()
11
+
12
+ # 日志格式
13
+ LOG_FORMAT_CONSOLE = (
14
+ "<green>{time:YYYY-MM-DD HH:mm:ss}</green> | "
15
+ "<level>{level:<8}</level> | "
16
+ "<cyan>[{extra[trigger]}]</cyan> "
17
+ "<level>{message}</level>"
18
+ )
19
+
20
+ LOG_FORMAT_FILE = (
21
+ "{time:YYYY-MM-DD HH:mm:ss} | {level:<8} | [{extra[trigger]:<8}] | "
22
+ "{name}:{function}:{line} - {message}"
23
+ )
24
+
25
+ # 配置默认 trigger(防止 KeyError)
26
+ logger.configure(extra={"trigger": "unknown"})
27
+
28
+ # 文件输出:永远记录 DEBUG 级别(带轮转和压缩)
29
+ logger.add(
30
+ LOG_FILE,
31
+ format=LOG_FORMAT_FILE,
32
+ level="DEBUG", # 文件永远记录全量日志
33
+ encoding="utf-8",
34
+ rotation="10 MB",
35
+ retention="7 days",
36
+ compression="zip",
37
+ )
38
+
39
+ # 控制台 handler ID,用于动态切换级别
40
+ _console_handler_id: int | None = None
41
+
42
+
43
+ def setup_console(verbose: bool = False) -> None:
44
+ """配置输出级别
45
+
46
+ Args:
47
+ verbose: True 时输出 DEBUG 级别,否则输出 INFO 级别
48
+ """
49
+ global _console_handler_id
50
+
51
+ # 移除旧的控制台 handler
52
+ if _console_handler_id is not None:
53
+ logger.remove(_console_handler_id)
54
+
55
+ level = "DEBUG" if verbose else "INFO"
56
+ _console_handler_id = logger.add(
57
+ sys.stderr,
58
+ format=LOG_FORMAT_CONSOLE,
59
+ level=level,
60
+ colorize=True,
61
+ )
62
+
63
+
64
+ # 默认 INFO 级别控制台输出
65
+ setup_console(verbose=False)
66
+
67
+ __all__ = ["logger", "setup_console"]
@@ -0,0 +1,203 @@
1
+ """网络状态检测、登录、持续保活"""
2
+
3
+ import sys
4
+ import time
5
+ from enum import Enum, auto
6
+
7
+ import requests
8
+ from playwright.sync_api import TimeoutError as PlaywrightTimeout
9
+ from playwright.sync_api import sync_playwright
10
+
11
+ from .constants import GATEWAY_URL, LOG_FILE, LOGIN_URL
12
+ from .log import logger
13
+
14
+
15
+ class LoginError(Exception):
16
+ """登录失败异常。"""
17
+
18
+ pass
19
+
20
+
21
+ class NetworkStatus(Enum):
22
+ """网络状态枚举。"""
23
+
24
+ UNKNOWN_NETWORK = auto() # 非校园网环境(DNS 解析失败或超时)
25
+ LOGGED_OUT = auto() # 校园网环境,未登录
26
+ LOGGED_IN = auto() # 校园网环境,已登录
27
+
28
+
29
+ # region 网络状态检测
30
+
31
+
32
+ def get_status() -> NetworkStatus:
33
+ """获取当前网络状态。
34
+
35
+ 通过访问校园网网关 (https://gw.buaa.edu.cn/) 检测:
36
+ - 请求失败(DNS/超时)→ UNKNOWN_NETWORK
37
+ - URL 包含 "success" → LOGGED_IN
38
+ - 其他情况 → LOGGED_OUT
39
+
40
+ Returns:
41
+ NetworkStatus 枚举值。
42
+ """
43
+ log = logger.bind(trigger="status")
44
+ log.debug("正在检测网络状态...")
45
+
46
+ try:
47
+ response = requests.get(
48
+ GATEWAY_URL,
49
+ headers={"User-Agent": "Mozilla/5.0"},
50
+ timeout=5,
51
+ allow_redirects=True,
52
+ )
53
+ final_url = response.url
54
+
55
+ if "success" in final_url.lower():
56
+ log.debug(f"网络状态: 已登录 (URL: {final_url})")
57
+ return NetworkStatus.LOGGED_IN
58
+ else:
59
+ log.debug(f"网络状态: 未登录 (URL: {final_url})")
60
+ return NetworkStatus.LOGGED_OUT
61
+
62
+ except requests.RequestException as e:
63
+ log.debug(f"网络状态: 非校园网环境 ({e})")
64
+ return NetworkStatus.UNKNOWN_NETWORK
65
+
66
+
67
+ # endregion
68
+
69
+
70
+ # region 登录
71
+
72
+
73
+ def login(username: str, password: str, *, headless: bool = True) -> None:
74
+ """使用 Playwright 模拟浏览器登录校园网。
75
+
76
+ Args:
77
+ username: 用户名。
78
+ password: 密码。
79
+ headless: 是否使用无头模式。
80
+
81
+ Raises:
82
+ LoginError: 登录失败时抛出,包含错误信息。
83
+ """
84
+ log = logger.bind(trigger="login")
85
+
86
+ # 先快速检查状态,避免不必要地启动浏览器
87
+ status = get_status()
88
+ if status == NetworkStatus.LOGGED_IN:
89
+ log.info("已经处于登录状态")
90
+ return
91
+ if status == NetworkStatus.UNKNOWN_NETWORK:
92
+ raise LoginError("未检测到校园网环境")
93
+
94
+ # 只有 LOGGED_OUT 时才启动浏览器
95
+ with sync_playwright() as p:
96
+ browser_path = p.chromium.executable_path
97
+ log.debug(f"使用浏览器: {browser_path}")
98
+
99
+ browser = p.chromium.launch(headless=headless, executable_path=browser_path)
100
+ context = browser.new_context()
101
+ page = context.new_page()
102
+
103
+ try:
104
+ log.info("正在打开登录页面...")
105
+ page.goto(LOGIN_URL, timeout=30000)
106
+ page.wait_for_load_state("networkidle", timeout=10000)
107
+
108
+ # 填写用户名和密码
109
+ log.debug("正在填写登录信息...")
110
+ page.locator("#username:visible").fill(username)
111
+ page.locator("#password:visible").fill(password)
112
+
113
+ # 点击登录按钮
114
+ log.info("正在提交登录...")
115
+ page.locator("#login").click()
116
+
117
+ page.wait_for_timeout(3000)
118
+
119
+ # 检查登录结果
120
+ if "success" in page.url.lower():
121
+ log.success("登录成功!")
122
+ return
123
+ else:
124
+ error_msg = _get_error_message(page)
125
+ log.warning(f"登录失败:{error_msg}")
126
+ raise LoginError(error_msg)
127
+
128
+ except PlaywrightTimeout as e:
129
+ log.error(f"页面加载超时:{e}")
130
+ raise LoginError(f"页面加载超时:{e}") from e
131
+ except LoginError:
132
+ raise
133
+ except Exception as e:
134
+ log.error(f"登录过程出错:{e}")
135
+ raise LoginError(f"{e}") from e
136
+ finally:
137
+ log.debug("正在关闭浏览器...")
138
+ browser.close()
139
+ log.debug("浏览器已关闭")
140
+
141
+
142
+ def _get_error_message(page) -> str:
143
+ """获取登录错误信息。"""
144
+ try:
145
+ # layer.js 弹窗错误信息
146
+ elem = page.locator(".layui-layer-content").first
147
+ if elem.is_visible(timeout=1000):
148
+ return elem.text_content() or "未知错误"
149
+ return "未知错误"
150
+ except Exception:
151
+ return "无法获取错误信息"
152
+
153
+
154
+ # endregion
155
+
156
+
157
+ # region 持续保活
158
+
159
+
160
+ def keep_alive(
161
+ username: str, password: str, check_interval_min: int, *, headless: bool = True
162
+ ):
163
+ """持续保持在线,检查登录状态并自动重连。
164
+
165
+ Args:
166
+ username: 校园网用户名。
167
+ password: 校园网密码。
168
+ check_interval_min: 检查间隔(分钟)。
169
+ headless: 是否使用无头模式运行浏览器。
170
+ """
171
+ log = logger.bind(trigger="run")
172
+ check_interval_sec = check_interval_min * 60
173
+
174
+ log.info(f"保活服务已启动,检查间隔: {check_interval_min} 分钟")
175
+ log.info(f"使用账户: {username}")
176
+ log.info(f"日志文件: {LOG_FILE}")
177
+
178
+ while True:
179
+ try:
180
+ status = get_status()
181
+
182
+ if status == NetworkStatus.UNKNOWN_NETWORK:
183
+ log.warning("未检测到校园网环境,等待下次检查...")
184
+ elif status == NetworkStatus.LOGGED_IN:
185
+ log.info("已登录,无需操作")
186
+ else: # LOGGED_OUT
187
+ log.warning("未登录,正在启动浏览器登录...")
188
+ try:
189
+ login(username, password, headless=headless)
190
+ log.success("登录成功")
191
+ except LoginError as e:
192
+ log.warning(f"登录未成功: {e}")
193
+
194
+ time.sleep(check_interval_sec)
195
+ except KeyboardInterrupt:
196
+ log.info("User Exit.")
197
+ sys.exit(0)
198
+ except Exception as e:
199
+ log.error(f"发生错误: {e}")
200
+ time.sleep(10)
201
+
202
+
203
+ # endregion
@@ -0,0 +1,11 @@
1
+ Metadata-Version: 2.3
2
+ Name: buaalogin-cli
3
+ Version: 0.1.0
4
+ Requires-Dist: playwright>=1.40.0
5
+ Requires-Dist: typer>=0.9.0
6
+ Requires-Dist: platformdirs>=4.0.0
7
+ Requires-Dist: loguru>=0.7.0
8
+ Requires-Dist: msgspec>=0.18.0
9
+ Requires-Dist: requests>=2.31.0
10
+ Requires-Python: >=3.11
11
+ Project-URL: Homepage, https://github.com/Misty02600/buaalogin-cli
@@ -0,0 +1,11 @@
1
+ buaalogin_cli/__init__.py,sha256=xJgs3LxemEaP9JsJ-BA9OhDJ78LozOw8i3HmHjDM9b0,40
2
+ buaalogin_cli/__main__.py,sha256=tlTuBxuEzJKIPa5Js84z86QLffGdkt0gI7ZG9SzbpTI,192
3
+ buaalogin_cli/cli.py,sha256=2pYLu7-vu5cyOpsXNiApxuHO5YLt8JN6Co40rsw2A9s,7019
4
+ buaalogin_cli/config.py,sha256=9c4ATq7qTuOMnNbt9JkvlSrRvOLWxKvRHK7ghmzBuJs,1414
5
+ buaalogin_cli/constants.py,sha256=Z7X0gF92Vr9lbCU3vMoFBU2S1OgHzuUg3rD5BNqQES4,392
6
+ buaalogin_cli/log.py,sha256=PkaBWkYbikqraOAxN5JHHuSi-M7_qI55oQCX84qGm5Q,1632
7
+ buaalogin_cli/service.py,sha256=PTQM-vKjXcxTyTmahnpTm9-kuDRt9OTJ-g7MW4aKmcU,6083
8
+ buaalogin_cli-0.1.0.dist-info/WHEEL,sha256=e_m4S054HL0hyR3CpOk-b7Q7fDX6BuFkgL5OjAExXas,80
9
+ buaalogin_cli-0.1.0.dist-info/entry_points.txt,sha256=saYSPX9fiGodsJR_0BNpG3SYj7fH5iEYF92172dHTjA,59
10
+ buaalogin_cli-0.1.0.dist-info/METADATA,sha256=hKFeoNe9xeSyfqDIF3ui7xgmu5loXW7zyjl8eF9sRtM,337
11
+ buaalogin_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.9.27
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ buaalogin = buaalogin_cli.__main__:main
3
+