campus-auth 1.0.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,6 @@
1
+ """campus-auth —— Dr.COM / eportal 校园网门户认证客户端。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ __version__ = "1.0.0"
6
+ __all__ = ["__version__"]
@@ -0,0 +1,8 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+
5
+ from .cli import main
6
+
7
+ if __name__ == "__main__":
8
+ sys.exit(main())
@@ -0,0 +1,58 @@
1
+ """适配层注册表。
2
+
3
+ 内置适配层在这里登记;也可以用 `--adapter module:attribute` 从任意
4
+ Python 文件加载(写学校适配层时不必改本文件,方便 PR 之外的自用)。
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import importlib
10
+ from typing import Final
11
+
12
+ from ..errors import ConfigError
13
+ from .base import Adapter, LoginResult, Param
14
+
15
+ BUILTIN: Final[dict[str, str]] = {
16
+ "huuc": "campus_auth.adapters.huuc:ADAPTER",
17
+ "generic": "campus_auth.adapters.example_generic:ADAPTER",
18
+ }
19
+
20
+ _cache: dict[str, Adapter] = {}
21
+
22
+
23
+ def _load_target(target: str) -> Adapter:
24
+ module_name, _, attr = target.partition(":")
25
+ attr = attr or "ADAPTER"
26
+ try:
27
+ module = importlib.import_module(module_name)
28
+ except ImportError as exc:
29
+ raise ConfigError(f"导出适配层失败({target}): {exc}") from exc
30
+ try:
31
+ adapter = getattr(module, attr)
32
+ except AttributeError as exc:
33
+ raise ConfigError(f"{module_name} 里没有 {attr!r}") from exc
34
+ if not isinstance(adapter, Adapter):
35
+ raise ConfigError(f"{target} 是 {type(adapter).__name__},不是 Adapter 实例")
36
+ adapter.check()
37
+ return adapter
38
+
39
+
40
+ def get_adapter(name: str) -> Adapter:
41
+ """按名字取适配层,`a:b` 形式走动态加载。"""
42
+ if not name:
43
+ raise ConfigError("没有指定适配层(adapter),跑 `campus-auth init` 会生成模板")
44
+ if name in _cache:
45
+ return _cache[name]
46
+ target = name if ":" in name else BUILTIN.get(name, "")
47
+ if not target:
48
+ raise ConfigError(f"没有适配层 {name!r},内置的有: {', '.join(BUILTIN)}")
49
+ adapter = _load_target(target)
50
+ _cache[name] = adapter
51
+ return adapter
52
+
53
+
54
+ def names() -> list[str]:
55
+ return sorted(BUILTIN)
56
+
57
+
58
+ __all__ = ["Adapter", "LoginResult", "Param", "BUILTIN", "get_adapter", "names"]
@@ -0,0 +1,272 @@
1
+ """适配层基类。
2
+
3
+ 一个适配层只描述"某所学校门户的差异",不实现 HTTP、重试、CLI。
4
+ 差异面被压缩成四件事:
5
+
6
+ 1. 请求怎么发:路径、方法、查询串、表单体(`Request`)
7
+ 2. 参数怎么编码(`key_style` + `encoder`)
8
+ 3. 账号怎么拼(前缀、运营商后缀)
9
+ 4. 响应怎么解读(`interpret_login` / `interpret_sessions`)
10
+
11
+ 默认行为(GET + JSONP + `list` 数组 + `result`/`ret_code`)覆盖 Dr.COM 系;
12
+ 某所学校需要变的东西,只覆盖对应方法,不重写 core。
13
+ 新增一所学校 = 抄一份 `example_generic.py` 改常量,别动 core。
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import random
19
+ from collections.abc import Callable, Mapping
20
+ from dataclasses import dataclass, field
21
+ from typing import Protocol
22
+
23
+ from ..crypto import xor_hex, xor_key
24
+
25
+ __all__ = ["Adapter", "LoginResult", "Param", "PortalLike", "Request"]
26
+
27
+
28
+ class Param(tuple):
29
+ """一个查询参数: (键, 明文值, 是否参与编码)。"""
30
+
31
+ def __new__(cls, key: str, value: str, encode: bool = True) -> Param:
32
+ return super().__new__(cls, (key, value, encode))
33
+
34
+ key = property(lambda self: self[0])
35
+ value = property(lambda self: self[1])
36
+ encode = property(lambda self: self[2])
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class Request:
41
+ """一次门户请求。`path` 可相对 `base_url`,也可给完整 URL(跨主机发现时用)。"""
42
+
43
+ path: str
44
+ method: str = "GET"
45
+ params: list[Param] = field(default_factory=list)
46
+ body: dict[str, str] = field(default_factory=dict)
47
+ headers: dict[str, str] = field(default_factory=dict)
48
+
49
+ @property
50
+ def is_absolute(self) -> bool:
51
+ return self.path.startswith(("http://", "https://"))
52
+
53
+
54
+ class PortalLike(Protocol):
55
+ """适配层眼里的 `Portal`:只承诺下面这些属性和动作。
56
+
57
+ 真 `Portal` 结构性满足它;测试里的假门户类型不参与运行时检查,照旧能塞。
58
+ """
59
+
60
+ base_url: str
61
+ ip: str
62
+ mac: str
63
+ account: str
64
+ password: str
65
+ operator: str
66
+ ctx: dict[str, object]
67
+
68
+ def raw(self, url: str, *, timeout: float | None = None) -> tuple[int, str]: ...
69
+
70
+ def request(self, request: Request) -> dict[str, object]: ...
71
+
72
+
73
+ @dataclass(frozen=True)
74
+ class LoginResult:
75
+ ok: bool
76
+ msg: str
77
+ ret_code: int | None = None
78
+ hint: str = ""
79
+
80
+
81
+ Encoder = Callable[[str], str]
82
+
83
+
84
+ @dataclass(frozen=True)
85
+ class Adapter:
86
+ name: str
87
+ school: str
88
+ base_url: str
89
+ key_style: str = "ip-xor"
90
+ account_prefix: str = ""
91
+ operator_suffixes: Mapping[str, str] = field(default_factory=dict)
92
+ js_version: str = ""
93
+ login_path: str = "/eportal/portal/login"
94
+ logout_path: str = "/eportal/portal/logout"
95
+ unbind_path: str = "/eportal/portal/mac/unbind"
96
+ online_path: str = "/eportal/portal/online_list"
97
+ callback_prefix: str = "dr"
98
+ timeout: float = 20.0
99
+ verify_tls: bool = False
100
+ status: str = "已验证"
101
+ notes: str = ""
102
+
103
+ # ---------- 编码 ----------
104
+
105
+ def encoder(self, ip: str) -> Encoder:
106
+ """返回一个把明文变密文的函数。"""
107
+ if self.key_style == "plain":
108
+ return lambda text: text
109
+ if self.key_style == "ip-xor":
110
+ key = xor_key(ip)
111
+ return lambda text: xor_hex(text, key)
112
+ raise ValueError(f"未知 key_style: {self.key_style!r}")
113
+
114
+ # ---------- 账号 ----------
115
+
116
+ def format_account(self, account: str, operator: str = "none") -> str:
117
+ suffix = self.operator_suffixes.get(operator, "") if operator else ""
118
+ return f"{self.account_prefix}{account}{suffix}"
119
+
120
+ def operator_names(self) -> list[str]:
121
+ return ["none", *self.operator_suffixes]
122
+
123
+ # ---------- 参数 ----------
124
+
125
+ def callback(self, action: str) -> str:
126
+ return f"{self.callback_prefix}{action}"
127
+
128
+ def account_params(self, account: str, password: str, operator: str) -> list[Param]:
129
+ return [
130
+ Param("user_account", self.format_account(account, operator)),
131
+ Param("user_password", password),
132
+ ]
133
+
134
+ def identity_params(self, ip: str, mac: str) -> list[Param]:
135
+ """每个请求都带的身份参数;`wlan_ac_ip` 等手段留空。"""
136
+ params = [
137
+ Param("wlan_user_ip", ip),
138
+ Param("wlan_user_ipv6", "", encode=False),
139
+ Param("wlan_user_mac", mac),
140
+ Param("wlan_ac_ip", "", encode=False),
141
+ Param("wlan_ac_name", "", encode=False),
142
+ ]
143
+ if self.js_version:
144
+ params.append(Param("jsVersion", self.js_version))
145
+ params += [
146
+ Param("captcha", "", encode=False),
147
+ Param("terminal_type", "1"),
148
+ Param("lang", "zh"),
149
+ Param("encrypt", "1", encode=False),
150
+ Param("v", self.nonce(), encode=False),
151
+ Param("lang", "zh", encode=False), # 前端确实发了两次 lang
152
+ ]
153
+ return params
154
+
155
+ @staticmethod
156
+ def nonce() -> str:
157
+ return f"0.{random.randint(10**15, 10**16 - 1)}"
158
+
159
+ # ---------- 发现(锐捷要门户地址,深澜要 challenge) ----------
160
+
161
+ def discover(self, portal: PortalLike, account: str) -> dict[str, object]:
162
+ """登录前的准备请求;默认什么都不做。结果放进 `portal.ctx`。"""
163
+ return {}
164
+
165
+ # ---------- 请求构造 ----------
166
+
167
+ def login_request(self, portal: PortalLike) -> Request:
168
+ ip, mac = portal.ip, portal.mac
169
+ cb = self.callback("1003")
170
+ return Request(
171
+ path=self.login_path,
172
+ params=[
173
+ Param("callback", cb),
174
+ Param("login_method", "1"),
175
+ *self.account_params(
176
+ portal.account,
177
+ portal.password,
178
+ portal.operator,
179
+ ),
180
+ *self.identity_params(ip, mac),
181
+ ],
182
+ )
183
+
184
+ def logout_request(self, portal: PortalLike) -> Request:
185
+ ip, mac = portal.ip, portal.mac
186
+ cb = self.callback("1004")
187
+ return Request(
188
+ path=self.logout_path,
189
+ params=[
190
+ Param("callback", cb),
191
+ Param("login_method", "1"),
192
+ Param("user_account", "drcom"),
193
+ Param("user_password", "123"),
194
+ Param("ac_logout", "1"),
195
+ Param("register_mode", "1"),
196
+ Param("wlan_vlan_id", "0"),
197
+ *self.identity_params(ip, mac),
198
+ ],
199
+ )
200
+
201
+ def unbind_request(self, portal: PortalLike) -> Request:
202
+ ip, mac = portal.ip, portal.mac
203
+ params = [
204
+ Param("callback", self.callback("1005")),
205
+ Param("user_account", portal.account),
206
+ Param("wlan_user_mac", mac),
207
+ Param("wlan_user_ip", ip),
208
+ ]
209
+ if self.js_version:
210
+ params.append(Param("jsVersion", self.js_version))
211
+ params += [
212
+ Param("encrypt", "1", encode=False),
213
+ Param("v", self.nonce(), encode=False),
214
+ Param("lang", "zh"),
215
+ Param("lang", "zh", encode=False),
216
+ ]
217
+ return Request(path=self.unbind_path, params=params)
218
+
219
+ def sessions_request(self, portal: PortalLike) -> Request:
220
+ return Request(path=self.online_path, params=self.online_params())
221
+
222
+ def online_params(self) -> list[Param]:
223
+ return [Param("callback", "cb", encode=False), Param("lang", "zh", encode=False)]
224
+
225
+ # ---------- 响应解读 ----------
226
+
227
+ def interpret_login(self, resp: Mapping[str, object]) -> LoginResult:
228
+ code = resp.get("ret_code")
229
+ ret_code = (
230
+ int(code) if isinstance(code, int | str) and str(code).lstrip("-").isdigit() else None
231
+ )
232
+ msg = str(resp.get("msg") or "")
233
+ ok = resp.get("result") in (1, "1", True) and ret_code in (0, None)
234
+ return LoginResult(ok=ok, msg=msg, ret_code=ret_code, hint=self.hint_for(ret_code, msg))
235
+
236
+ def hint_for(self, ret_code: int | None, msg: str) -> str:
237
+ """把服务端的含糊报错翻译成人话;子类按需覆盖。"""
238
+ if ret_code in (0, None):
239
+ return ""
240
+ return f"服务端返回 ret_code={ret_code},原文: {msg or '(空)'}"
241
+
242
+ def interpret_sessions(self, payload: Mapping[str, object]) -> list[Mapping[str, object]]:
243
+ """从会话接口的响应里取出会话行;默认认 `list` 数组。"""
244
+ rows = payload.get("list") or []
245
+ return [row for row in rows if isinstance(row, dict)] # type: ignore[misc]
246
+
247
+ def row_is_mine(self, row: Mapping[str, object], ip: str, mac: str, account: str = "") -> bool:
248
+ """IP 对上、且(给了账号时)账号也对上 —— 别人占着这个 IP 不算你的会话。"""
249
+ if str(row.get("online_ip") or "") != ip:
250
+ return False
251
+ if not account:
252
+ return True
253
+ actual = str(row.get("user_account") or row.get("user_name") or "")
254
+ return not actual or account in actual
255
+
256
+ def online_check(self, portal: PortalLike) -> bool | None:
257
+ """在线判定。返回 None 表示"走会话列表",返回 True/False 表示自己判定。"""
258
+ return None
259
+
260
+ def after_login(self, portal: PortalLike, payload: Mapping[str, object]) -> None:
261
+ """登录成功后收尾(比如存下 userIndex);默认什么都不做。"""
262
+
263
+ def zone_hint(self, ip: str, ssid: str | None) -> str:
264
+ return ""
265
+
266
+ # ---------- 自检 ----------
267
+
268
+ def check(self) -> None:
269
+ if self.key_style not in {"plain", "ip-xor"}:
270
+ raise ValueError(f"{self.name}: 未知 key_style {self.key_style!r}")
271
+ if not self.base_url.startswith(("http://", "https://")):
272
+ raise ValueError(f"{self.name}: base_url 必须以 http(s):// 开头")
@@ -0,0 +1,37 @@
1
+ """通用 eportal 适配层模板 —— 抄这份改成你的学校。
2
+
3
+ **先跑 `campus-auth probe --url <门户地址>` 确认是 Dr.COM**,判成别的就别往下写。
4
+
5
+ 改的时候按这个顺序核对(F12 → Network,作为普通用户点一次登录):
6
+
7
+ 1. 参数名不同 → 覆盖 `account_params()` / `identity_params()` 或整个 `login_request()`。
8
+ 2. 参数值是 `%xx` 十六进制 → `key_style="ip-xor"`,并把密钥算法写进 `encoder()` 的
9
+ 返回函数(常见是 MAC 或固定盐,密钥一致才有用)。
10
+ 3. 响应不是 JSON → 覆盖 `interpret_login()` 和 `hint_for()`。
11
+ 4. 路径不同 → 改 `login_path` / `logout_path` 这些字段。
12
+
13
+ 改完在 `adapters/__init__.py` 的 `BUILTIN` 登记,再跑
14
+ `campus-auth status --adapter <代号>` 和一次真机 `login`。
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from .base import Adapter
20
+
21
+
22
+ class GenericAdapter(Adapter):
23
+ def __init__(self) -> None:
24
+ super().__init__(
25
+ name="generic",
26
+ school="未具名学校(模板)",
27
+ base_url="http://10.0.0.1:8080",
28
+ key_style="plain",
29
+ account_prefix=",0,",
30
+ operator_suffixes={},
31
+ js_version="4.2.1",
32
+ status="模板",
33
+ notes="改 base_url 和各路径后先跑 `campus-auth status` 验证能不能连通。",
34
+ )
35
+
36
+
37
+ ADAPTER = GenericAdapter()
@@ -0,0 +1,92 @@
1
+ """河南城建学院(HUUC)适配层 —— UCLINK / UCSIG 门户。
2
+
3
+ 协议来自门户前端 `a41.js` 的 `getkey` / `enc_pwd` 以及抓包核对:
4
+
5
+ * 密钥 = `wlan_user_ip` 各字符 ASCII 逐位异或
6
+ * 账号要带 `,0,` 前缀;宿舍(UCSIG)还要挂运营商后缀
7
+ * `wlan_user_mac` 服务端不校验,可填全 0
8
+ * 成功与否一律以 `online_list` 里出现自己的 IP 为准,别信 `msg`
9
+
10
+ 宿舍联通/电信多数账号还没在运营商侧开通,现象是 `unbind isp uid` /
11
+ `AC认证失败` —— 那是运营商的问题,不是密码错。
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from ..adapters.base import Adapter, LoginResult
17
+ from ..crypto import xor_key
18
+
19
+ PORTAL_IP = "10.210.213.208"
20
+
21
+ # (区域说明, SSID, 建议后缀)
22
+ SSID_ZONES = {
23
+ "UCLINK": ("教学楼/图书馆", "UCLINK", "none"),
24
+ "UCSIG": ("宿舍", "UCSIG", "cmcc"),
25
+ }
26
+ SUBNET_ZONES = {
27
+ "10.152.": ("教学楼/图书馆", "UCLINK", "none"),
28
+ "10.138.": ("宿舍", "UCSIG", "cmcc"),
29
+ }
30
+
31
+ HINTS = {
32
+ "unbind isp uid": "运营商侧还没绑定这个账号:宿舍请确认已开通宽带,或换 -w cmcc。",
33
+ "AC认证失败": "AC 认证失败,多因运营商侧未开通或该 SSID 只收特定运营商。",
34
+ "账号或密码错误": "账号或密码不对;账号是纯数字学号,不要带 @ 后缀(后缀由 -w 拼)。",
35
+ "用户已在线": "已经在线了,直接跑 status 看看。",
36
+ "没有可解析的 JSON": "门户回的是页面不是 JSON:确认 base_url 指到 eportal 入口,不是门户首页。",
37
+ }
38
+
39
+
40
+ class HuucAdapter(Adapter):
41
+ def __init__(self) -> None:
42
+ super().__init__(
43
+ name="huuc",
44
+ school="河南城建学院",
45
+ base_url="https://netauth.huuc.edu.cn:802",
46
+ key_style="ip-xor",
47
+ account_prefix=",0,",
48
+ operator_suffixes={"cmcc": "@cmcc", "unicom": "@unicom", "dx": "@dx"},
49
+ js_version="4.2.1",
50
+ notes=f"门户 IP {PORTAL_IP};netauth.huuc.edu.cn:802。",
51
+ )
52
+
53
+ def zone_of(self, ip: str, ssid: str | None) -> tuple[str, str | None, str | None, str]:
54
+ """(区域, SSID, 应选后缀, 依据)。依据是 ssid / subnet / none。"""
55
+ if ssid and ssid in SSID_ZONES:
56
+ return (*SSID_ZONES[ssid], "ssid")
57
+ for prefix, info in SUBNET_ZONES.items():
58
+ if ip.startswith(prefix):
59
+ return (*info, "subnet")
60
+ return ("未知", ssid, None, "none")
61
+
62
+ def zone_hint(self, ip: str, ssid: str | None) -> str:
63
+ area, seen, want, source = self.zone_of(ip, ssid)
64
+ if want is None:
65
+ return f"没认出所在区域(SSID {seen or '未知'}),如果在宿舍要手动加 -w cmcc。"
66
+ if want == "none":
67
+ return ""
68
+ if want == "cmcc":
69
+ return f"看着像{area}({source}),宿舍一般要加 -w cmcc,否则多半 AC 认证失败。"
70
+ return ""
71
+
72
+ def hint_for(self, ret_code: int | None, msg: str) -> str:
73
+ for needle, hint in HINTS.items():
74
+ if needle in msg:
75
+ return hint
76
+ if "AC认证失败" in msg or "isp uid" in msg:
77
+ return HINTS["AC认证失败"]
78
+ return super().hint_for(ret_code, msg)
79
+
80
+ def interpret_login(self, resp):
81
+ result = super().interpret_login(resp)
82
+ if not result.ok and not result.hint:
83
+ result = LoginResult(False, result.msg, result.ret_code, "服务端拒绝但没给可读原因。")
84
+ return result
85
+
86
+
87
+ ADAPTER = HuucAdapter()
88
+
89
+
90
+ def key_for(ip: str) -> int:
91
+ """便于排查:直接看某个 IP 派生出的密钥。"""
92
+ return xor_key(ip)
campus_auth/cli.py ADDED
@@ -0,0 +1,165 @@
1
+ """命令行入口:只有 parser、分之调度和退出码。
2
+
3
+ 各子命令的实现都在 `commands.py`。
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import argparse
9
+ import contextlib
10
+ import os
11
+ import sys
12
+ import traceback
13
+
14
+ from . import __version__, adapters
15
+ from .commands import (
16
+ _dump,
17
+ _err,
18
+ cmd_adapters,
19
+ cmd_check,
20
+ cmd_init,
21
+ cmd_install,
22
+ cmd_login,
23
+ cmd_logout,
24
+ cmd_password,
25
+ cmd_probe,
26
+ cmd_status,
27
+ cmd_unbind,
28
+ cmd_uninstall,
29
+ cmd_urls,
30
+ cmd_watch,
31
+ )
32
+ from .errors import (
33
+ EXIT_CONFIG,
34
+ EXIT_ENV,
35
+ EXIT_OK,
36
+ CampusAuthError,
37
+ ConfigError,
38
+ EnvironmentError_,
39
+ )
40
+
41
+ __all__ = ["main"]
42
+
43
+
44
+ def _parser() -> argparse.ArgumentParser:
45
+ parser = argparse.ArgumentParser(
46
+ prog="campus-auth",
47
+ description="Dr.COM / eportal 校园网门户认证客户端",
48
+ epilog="适配层用 --adapter 选,内置: " + ", ".join(adapters.names()),
49
+ )
50
+ parser.add_argument("--version", action="version", version=f"campus-auth {__version__}")
51
+ parser.add_argument(
52
+ "--show-secrets", action="store_true", help="报错里的密码不打码(默认打码)"
53
+ )
54
+ common = argparse.ArgumentParser(add_help=False)
55
+ common.add_argument("--config", help="配置文件路径")
56
+ common.add_argument("--adapter", help="适配层名,或 module:attr")
57
+ common.add_argument("-a", "--account", help="学号(纯数字,不带 @ 后缀)")
58
+ common.add_argument("-w", "--operator", help="运营商 none/cmcc/unicom/dx")
59
+ common.add_argument("-p", "--password", help="密码(不推荐,会留在历史里)")
60
+ common.add_argument("--password-stdin", action="store_true", help="从标准输入读密码")
61
+ common.add_argument("--mac", help="覆盖 MAC(12 位十六进制)")
62
+ common.add_argument("--ip", help="覆盖本机 IP(离线生成 URL 时用)")
63
+ common.add_argument("--url", help="覆盖门户地址")
64
+ common.add_argument("--timeout", type=float, help="单次请求超时秒数")
65
+ common.add_argument("--retries", type=int, help="传输重试次数")
66
+ common.add_argument("--verify-tls", action="store_true", help="校验证书")
67
+ common.add_argument("--json", action="store_true", help="输出 JSON")
68
+
69
+ sub = parser.add_subparsers(dest="command", required=True)
70
+
71
+ init = sub.add_parser("init", parents=[common], help="生成配置模板")
72
+ init.add_argument("-o", "--output", help="写到哪个文件")
73
+ init.add_argument("-f", "--force", action="store_true", help="覆盖已有文件")
74
+ init.set_defaults(func=cmd_init)
75
+
76
+ sub.add_parser("adapters", parents=[common], help="列出内置适配层").set_defaults(
77
+ func=cmd_adapters
78
+ )
79
+ login_p = sub.add_parser("login", parents=[common], help="登录")
80
+ login_p.add_argument("--remember", action="store_true", help="成功后把密码存进系统钥匙串")
81
+ login_p.set_defaults(func=cmd_login)
82
+ sub.add_parser("logout", parents=[common], help="注销").set_defaults(func=cmd_logout)
83
+ sub.add_parser("unbind", parents=[common], help="解除 MAC 绑定").set_defaults(func=cmd_unbind)
84
+ sub.add_parser("status", parents=[common], help="查看在线会话").set_defaults(func=cmd_status)
85
+
86
+ check = sub.add_parser("check", parents=[common], help="只查在线状态,用退出码表达")
87
+ check.add_argument("-q", "--quiet", action="store_true")
88
+ check.set_defaults(func=cmd_check)
89
+
90
+ probe_p = sub.add_parser(
91
+ "probe", parents=[common], help="探测门户是不是 Dr.COM(--url 给地址)"
92
+ )
93
+ probe_p.set_defaults(func=cmd_probe)
94
+
95
+ urls = sub.add_parser("urls", parents=[common], help="为当前 IP 生成快捷方式")
96
+ urls.add_argument("-o", "--output", help="输出目录")
97
+ urls.set_defaults(func=cmd_urls)
98
+
99
+ watch_p = sub.add_parser("watch", parents=[common], help="断线自动重连")
100
+ watch_p.add_argument("--once", action="store_true", help="只跑一轮")
101
+ watch_p.add_argument("--cycles", type=int, help="跑多少轮后退出")
102
+ watch_p.add_argument("--interval", type=float, help="两轮间隔秒数")
103
+ watch_p.add_argument("--log", help="同时写进这个文件(按 1 MiB 轮转,留一份 .1)")
104
+ watch_p.set_defaults(func=cmd_watch)
105
+
106
+ password_p = sub.add_parser("password", parents=[common], help="把密码存进/删出系统钥匙串")
107
+ password_p.add_argument("--forget", action="store_true", help="删掉存着的密码")
108
+ password_p.add_argument("--show", action="store_true", help="只看有没有存,不写入")
109
+ password_p.set_defaults(func=cmd_password)
110
+
111
+ install_p = sub.add_parser(
112
+ "install", parents=[common], help="把 watch 装成登录自启(计划任务 / systemd / launchd)"
113
+ )
114
+ install_p.add_argument("--dry-run", action="store_true", help="只打印要写什么、跑什么")
115
+ install_p.set_defaults(func=cmd_install)
116
+
117
+ uninstall_p = sub.add_parser("uninstall", parents=[common], help="卸掉 install 装的自启")
118
+ uninstall_p.add_argument("--dry-run", action="store_true", help="只打印要跑什么")
119
+ uninstall_p.set_defaults(func=cmd_uninstall)
120
+ return parser
121
+
122
+
123
+ def _force_utf8() -> None:
124
+ """重定向到文件/管道时也保证输出是 UTF-8,别变成 GBK 乱码。"""
125
+ for stream in (sys.stdout, sys.stderr):
126
+ with contextlib.suppress(AttributeError, ValueError, OSError):
127
+ stream.reconfigure(encoding="utf-8", errors="replace")
128
+
129
+
130
+ def main(argv: list[str] | None = None) -> int:
131
+ _force_utf8()
132
+ args = _parser().parse_args(argv)
133
+ try:
134
+ return args.func(args)
135
+ except ConfigError as exc:
136
+ _err(f"配置: {exc}")
137
+ return EXIT_CONFIG
138
+ except EnvironmentError_ as exc:
139
+ _err(f"环境: {exc}")
140
+ return EXIT_ENV
141
+ except CampusAuthError as exc:
142
+ _err(str(exc))
143
+ if args.json:
144
+ _dump({"ok": False, "error": str(exc)})
145
+ return exc.exit_code
146
+ except KeyboardInterrupt:
147
+ _err("已中断")
148
+ return 130
149
+ except (BrokenPipeError, OSError) as exc:
150
+ # `campus-auth ... | head` 会在这里断掉;Windows 上是 errno 22,POSIX 是 32
151
+ if isinstance(exc, BrokenPipeError) or getattr(exc, "errno", None) in {22, 32}:
152
+ with contextlib.suppress(OSError, ValueError):
153
+ sys.stdout = open(os.devnull, "w") # noqa: SIM115
154
+ return EXIT_OK
155
+ _err(f"意外错误: {exc!r}")
156
+ traceback.print_exc()
157
+ return 70
158
+ except Exception as exc: # 未预期的错误给 traceback,别静默
159
+ _err(f"意外错误: {exc!r}")
160
+ traceback.print_exc()
161
+ return 70
162
+
163
+
164
+ if __name__ == "__main__":
165
+ sys.exit(main())