buaalogin-cli 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.
- buaalogin_cli-0.1.0/PKG-INFO +11 -0
- buaalogin_cli-0.1.0/pyproject.toml +129 -0
- buaalogin_cli-0.1.0/src/buaalogin_cli/__init__.py +1 -0
- buaalogin_cli-0.1.0/src/buaalogin_cli/__main__.py +12 -0
- buaalogin_cli-0.1.0/src/buaalogin_cli/cli.py +227 -0
- buaalogin_cli-0.1.0/src/buaalogin_cli/config.py +47 -0
- buaalogin_cli-0.1.0/src/buaalogin_cli/constants.py +15 -0
- buaalogin_cli-0.1.0/src/buaalogin_cli/log.py +67 -0
- buaalogin_cli-0.1.0/src/buaalogin_cli/service.py +203 -0
|
@@ -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,129 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "buaalogin-cli"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
requires-python = ">=3.11"
|
|
5
|
+
dependencies = [
|
|
6
|
+
"playwright>=1.40.0",
|
|
7
|
+
"typer>=0.9.0",
|
|
8
|
+
"platformdirs>=4.0.0",
|
|
9
|
+
"loguru>=0.7.0",
|
|
10
|
+
"msgspec>=0.18.0",
|
|
11
|
+
"requests>=2.31.0",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[dependency-groups]
|
|
15
|
+
dev = [
|
|
16
|
+
"basedpyright>=1.16.0",
|
|
17
|
+
"commitizen>=4.1.0",
|
|
18
|
+
"git-cliff>=2.11.0,<3.0.0",
|
|
19
|
+
"prek>=0.2.0",
|
|
20
|
+
"ruff>=0.14.13,<1.0.0",
|
|
21
|
+
{ include-group = "test" },
|
|
22
|
+
]
|
|
23
|
+
test = [
|
|
24
|
+
"pytest>=8.0.0",
|
|
25
|
+
"pytest-asyncio>=1.3.0,<1.4.0",
|
|
26
|
+
"pytest-cov>=7.0.0",
|
|
27
|
+
"pytest-xdist>=3.8.0,<4.0.0",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[project.urls]
|
|
31
|
+
Homepage = "https://github.com/Misty02600/buaalogin-cli"
|
|
32
|
+
|
|
33
|
+
[project.scripts]
|
|
34
|
+
buaalogin = "buaalogin_cli.__main__:main"
|
|
35
|
+
|
|
36
|
+
[build-system]
|
|
37
|
+
requires = ["uv_build>=0.9.2,<0.10.0"]
|
|
38
|
+
build-backend = "uv_build"
|
|
39
|
+
|
|
40
|
+
[tool.commitizen]
|
|
41
|
+
name = "cz_conventional_commits"
|
|
42
|
+
version = "0.1.0" # 当前版本
|
|
43
|
+
tag_format = "v$version"
|
|
44
|
+
version_files = ["pyproject.toml:^version"]
|
|
45
|
+
major_version_zero = true # 允许 0.x.x 版本号
|
|
46
|
+
|
|
47
|
+
[tool.coverage.report]
|
|
48
|
+
exclude_lines = [
|
|
49
|
+
"raise NotImplementedError",
|
|
50
|
+
"if TYPE_CHECKING:",
|
|
51
|
+
"@overload",
|
|
52
|
+
"except ImportError:",
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
[tool.pyright]
|
|
56
|
+
include = ["src", "tests"] # 要检查的目录
|
|
57
|
+
pythonVersion = "3.11" # 目标 Python 版本(影响可用的类型特性)
|
|
58
|
+
pythonPlatform = "All" # 目标平台:All | Linux | Windows | Darwin
|
|
59
|
+
# typeCheckingMode 类型检查严格程度,可选值:
|
|
60
|
+
# - "off": 关闭类型检查
|
|
61
|
+
# - "basic": 基础检查(仅报告明确的类型错误)
|
|
62
|
+
# - "standard": 标准检查(推荐,平衡严格性与实用性)
|
|
63
|
+
# - "strict": 严格检查(要求完整的类型注解,适合新项目)
|
|
64
|
+
# - "all": 最严格(启用所有检查,可能产生大量警告)
|
|
65
|
+
typeCheckingMode = "standard"
|
|
66
|
+
|
|
67
|
+
# 测试目录的特殊配置(放宽部分规则以适应测试场景)
|
|
68
|
+
[[tool.pyright.executionEnvironments]]
|
|
69
|
+
root = "tests"
|
|
70
|
+
reportPrivateUsage = "none" # 允许测试访问私有属性 (_variable)
|
|
71
|
+
reportUnknownMemberType = "none" # 允许 Mock 对象等动态类型
|
|
72
|
+
|
|
73
|
+
[tool.pytest.ini_options]
|
|
74
|
+
addopts = [
|
|
75
|
+
"--import-mode=importlib", # 使用 importlib 导入模式(推荐,避免 sys.path 污染)
|
|
76
|
+
"--strict-markers", # 严格标记模式(未注册的 marker 会报错)
|
|
77
|
+
"--tb=short", # 简短的错误回溯(减少输出噪音)
|
|
78
|
+
"-ra", # 显示所有非通过测试的摘要(a=all except passed)
|
|
79
|
+
"--ignore=experiments", # 忽略实验性测试目录
|
|
80
|
+
]
|
|
81
|
+
testpaths = ["tests"] # 仅运行 tests 目录下的测试
|
|
82
|
+
pythonpath = ["src", "tests"] # 添加到 Python 路径,确保模块可导入
|
|
83
|
+
asyncio_mode = "auto" # 自动检测异步测试函数,无需手动标记 @pytest.mark.asyncio
|
|
84
|
+
asyncio_default_fixture_loop_scope = "function" # 每个测试函数使用独立的事件循环
|
|
85
|
+
|
|
86
|
+
[tool.ruff]
|
|
87
|
+
line-length = 88 # 每行最大字符数(与 Black 保持一致)
|
|
88
|
+
src = ["src", "tests"] # 源代码目录(用于判断导入是本地模块还是第三方模块)
|
|
89
|
+
exclude = ["experiments"] # 实验性代码,不进行 lint 检查
|
|
90
|
+
|
|
91
|
+
[tool.ruff.format]
|
|
92
|
+
line-ending = "lf" # 行尾符:lf(Unix)| crlf(Windows)| auto | native
|
|
93
|
+
|
|
94
|
+
[tool.ruff.lint]
|
|
95
|
+
# 启用的规则集,每个字母代码对应一组规则
|
|
96
|
+
select = [
|
|
97
|
+
"F", # Pyflakes:基础语法和逻辑错误检查
|
|
98
|
+
"W", # pycodestyle warnings:PEP 8 风格警告
|
|
99
|
+
"E", # pycodestyle errors:PEP 8 风格错误
|
|
100
|
+
"I", # isort:导入语句排序
|
|
101
|
+
"B", # flake8-bugbear:发现潜在 bug 和设计问题
|
|
102
|
+
"UP", # pyupgrade:升级到新版 Python 语法
|
|
103
|
+
"ASYNC", # flake8-async:异步代码最佳实践
|
|
104
|
+
"C4", # flake8-comprehensions:推导式优化建议
|
|
105
|
+
"T10", # flake8-debugger:检测遗留的调试器语句(如 pdb)
|
|
106
|
+
"T20", # flake8-print:检测 print 语句(生产代码应使用 logging)
|
|
107
|
+
"PYI", # flake8-pyi:类型存根文件(.pyi)规范检查
|
|
108
|
+
"PT", # flake8-pytest-style:pytest 代码风格检查
|
|
109
|
+
"Q", # flake8-quotes:引号使用规范
|
|
110
|
+
"TID", # flake8-tidy-imports:导入语句整理
|
|
111
|
+
"RUF", # Ruff-specific:Ruff 特有的规则
|
|
112
|
+
]
|
|
113
|
+
# 忽略的规则(根据项目需求放宽)
|
|
114
|
+
ignore = [
|
|
115
|
+
"E501", # 行长度由 formatter 控制,lint 不重复检查
|
|
116
|
+
"E402", # 允许模块导入不在文件顶部(某些情况下需要先执行代码)
|
|
117
|
+
"UP037", # 允许使用引号包裹的类型注解(前向引用)
|
|
118
|
+
"RUF001", # 允许字符串中的中文等 Unicode 字符
|
|
119
|
+
"RUF002", # 允许文档字符串中的中文等 Unicode 字符
|
|
120
|
+
"RUF003", # 允许注释中的中文等 Unicode 字符
|
|
121
|
+
"W191", # 允许使用制表符缩进
|
|
122
|
+
"TID252", # 允许使用相对导入
|
|
123
|
+
"B008", # 允许在函数参数默认值中使用函数调用
|
|
124
|
+
]
|
|
125
|
+
|
|
126
|
+
[tool.ruff.lint.isort]
|
|
127
|
+
extra-standard-library = [
|
|
128
|
+
"typing_extensions",
|
|
129
|
+
] # 将 typing_extensions 视为标准库line-length = 88 # 每行最大字符数(与 Black 保持一致)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""北航校园网自动登录工具"""
|
|
@@ -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
|
|
@@ -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
|