bb-sync 1.9.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.
- bb_sync/__init__.py +23 -0
- bb_sync/__main__.py +8 -0
- bb_sync/blackboard/__init__.py +5 -0
- bb_sync/blackboard/downloader.py +348 -0
- bb_sync/blackboard/login.py +190 -0
- bb_sync/blackboard/models.py +143 -0
- bb_sync/blackboard/scraper.py +695 -0
- bb_sync/browser/__init__.py +5 -0
- bb_sync/browser/steel.py +511 -0
- bb_sync/cli.py +302 -0
- bb_sync/commands/__init__.py +5 -0
- bb_sync/commands/auth.py +80 -0
- bb_sync/commands/config.py +239 -0
- bb_sync/commands/course.py +45 -0
- bb_sync/commands/doctor.py +65 -0
- bb_sync/commands/due.py +66 -0
- bb_sync/commands/run.py +74 -0
- bb_sync/context.py +69 -0
- bb_sync/core/__init__.py +5 -0
- bb_sync/core/config.py +205 -0
- bb_sync/core/config_store.py +190 -0
- bb_sync/core/doctor.py +230 -0
- bb_sync/core/due.py +192 -0
- bb_sync/core/errors.py +107 -0
- bb_sync/core/output.py +151 -0
- bb_sync/core/service.py +446 -0
- bb_sync/core/update.py +286 -0
- bb_sync/creds.py +91 -0
- bb_sync/paths.py +26 -0
- bb_sync-1.9.0.dist-info/METADATA +265 -0
- bb_sync-1.9.0.dist-info/RECORD +34 -0
- bb_sync-1.9.0.dist-info/WHEEL +4 -0
- bb_sync-1.9.0.dist-info/entry_points.txt +2 -0
- bb_sync-1.9.0.dist-info/licenses/LICENSE +21 -0
bb_sync/__init__.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""bb-sync — Blackboard 课程资源自动同步。
|
|
2
|
+
|
|
3
|
+
对外暴露版本号与核心异常,具体功能走各子包:
|
|
4
|
+
|
|
5
|
+
- :mod:`bb_sync.cli` Typer 命令行入口
|
|
6
|
+
- :mod:`bb_sync.core` 配置、输出、异常等基础设施
|
|
7
|
+
- :mod:`bb_sync.blackboard` Blackboard 站点交互(登录 / 抓取 / 下载)
|
|
8
|
+
- :mod:`bb_sync.browser` Steel 浏览器后端
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import importlib.metadata as metadata
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
__version__ = metadata.version("bb-sync")
|
|
17
|
+
except metadata.PackageNotFoundError: # 源码直跑、未安装时
|
|
18
|
+
__version__ = "0.0.0.dev0"
|
|
19
|
+
|
|
20
|
+
#: 问题反馈入口;错误提示统一引用,避免多处硬编码
|
|
21
|
+
ISSUES_URL = "https://github.com/asdukw/cuhk-shenzhen-blackboard-sync/issues"
|
|
22
|
+
|
|
23
|
+
__all__ = ["ISSUES_URL", "__version__"]
|
bb_sync/__main__.py
ADDED
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
"""附件下载:文件名推断、类型嗅探、增量去重、浏览器原生流式落盘。
|
|
2
|
+
|
|
3
|
+
服务器常返回 ``application/octet-stream``,且 BB 条目标题往往没有扩展名
|
|
4
|
+
(就叫 "Lec 01"),所以文件名按以下优先级推断:
|
|
5
|
+
|
|
6
|
+
1. 链接文本
|
|
7
|
+
2. ``Content-Disposition`` 里的 ``filename``
|
|
8
|
+
3. URL 末段
|
|
9
|
+
|
|
10
|
+
再依次用 ``Content-Type`` → **文件头嗅探** → URL 末段后缀 补全扩展名。
|
|
11
|
+
|
|
12
|
+
下载分两步走:
|
|
13
|
+
|
|
14
|
+
- **探测**:``HEAD`` 廉价拿文件名与大小,先做增量去重;名字缺扩展名时才补读文件头。
|
|
15
|
+
- **下载**:确实需要新文件时,用页面里的 ``<a download>`` 触发 Chrome 自身下载,
|
|
16
|
+
由浏览器把文件**直接流式写到磁盘**(不再把整个文件读进内存)。落盘统一先写
|
|
17
|
+
``.part`` 再 ``os.replace`` 原子替换。
|
|
18
|
+
|
|
19
|
+
选择浏览器通道而不是自建 HTTP 客户端的原因:下载与登录共用同一套浏览器网络栈和
|
|
20
|
+
cookie,不会出现「浏览器能下、脚本直连被 TLS/代理拒绝」这类不一致(实测踩到过)。
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import contextlib
|
|
26
|
+
import mimetypes
|
|
27
|
+
import os
|
|
28
|
+
import re
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from urllib.parse import unquote
|
|
31
|
+
|
|
32
|
+
from bb_sync.blackboard.models import FileItem
|
|
33
|
+
from bb_sync.blackboard.scraper import sanitize_filename
|
|
34
|
+
from bb_sync.core.errors import NetworkError
|
|
35
|
+
|
|
36
|
+
#: 作业编号,用于归入 ``assignments/hwN/``
|
|
37
|
+
HW_NUM = re.compile(r"(?:hw|home\s*work|set|assignment)\D{0,3}(\d{1,2})", re.I)
|
|
38
|
+
|
|
39
|
+
#: 大于该体积的文件才参与「体积+后缀」去重
|
|
40
|
+
SIZE_DEDUP_THRESHOLD = 50_000
|
|
41
|
+
|
|
42
|
+
#: 视为「没给出真实扩展名」的后缀
|
|
43
|
+
_UNKNOWN_SUFFIXES = {".bin", ".exe", ".dat"}
|
|
44
|
+
|
|
45
|
+
#: 未完成下载的临时文件后缀;``build_index`` 会忽略它
|
|
46
|
+
PART_SUFFIX = ".part"
|
|
47
|
+
|
|
48
|
+
#: 判断类型/去重所需的最少字节数(覆盖 ipynb 的 ``"cells"`` 探测窗口)
|
|
49
|
+
SNIFF_BYTES = 4096
|
|
50
|
+
|
|
51
|
+
#: HEAD 探测 / 嗅探文件头的超时(毫秒)
|
|
52
|
+
PROBE_TIMEOUT_MS = 30_000
|
|
53
|
+
|
|
54
|
+
#: 等待浏览器「开始下载」的超时(毫秒);超时即认为浏览器通道不可用并熔断
|
|
55
|
+
DOWNLOAD_START_TIMEOUT_MS = 15_000
|
|
56
|
+
|
|
57
|
+
#: 兜底路线整块读取的超时(毫秒)
|
|
58
|
+
BUFFERED_TIMEOUT_MS = 60_000
|
|
59
|
+
|
|
60
|
+
#: 在页面里触发浏览器下载:同源 URL + download 属性,无论服务端是否 attachment 都会下载
|
|
61
|
+
_TRIGGER_DOWNLOAD_JS = """
|
|
62
|
+
(url) => {
|
|
63
|
+
const a = document.createElement('a');
|
|
64
|
+
a.href = url;
|
|
65
|
+
a.download = '';
|
|
66
|
+
a.style.display = 'none';
|
|
67
|
+
document.body.appendChild(a);
|
|
68
|
+
a.click();
|
|
69
|
+
a.remove();
|
|
70
|
+
}
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def sniff_ext(body: bytes) -> str | None:
|
|
75
|
+
"""按文件头嗅探真实类型(服务器常返回 application/octet-stream)。"""
|
|
76
|
+
if body.startswith(b"%PDF"):
|
|
77
|
+
return ".pdf"
|
|
78
|
+
if body.startswith(b"PK\x03\x04"):
|
|
79
|
+
return ".zip"
|
|
80
|
+
if body.startswith(b"\xd0\xcf\x11\xe0"):
|
|
81
|
+
return ".doc"
|
|
82
|
+
head = body[:400].lstrip()
|
|
83
|
+
if head.startswith(b"{"):
|
|
84
|
+
if b'"cells"' in body[:4000]:
|
|
85
|
+
return ".ipynb"
|
|
86
|
+
return ".json"
|
|
87
|
+
if head.startswith(b"<?xml") or head.lower().startswith(b"<html"):
|
|
88
|
+
return ".xml"
|
|
89
|
+
if body.startswith(b"\x1f\x8b"):
|
|
90
|
+
return ".gz"
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _name_from_disposition(headers) -> str | None:
|
|
95
|
+
"""从 ``Content-Disposition`` 提取文件名。"""
|
|
96
|
+
cd = str(headers.get("content-disposition", "") or "")
|
|
97
|
+
m = re.search(r"filename\*?=(?:UTF-8'')?\"?([^\";]+)", cd, re.I)
|
|
98
|
+
return unquote(m.group(1)) if m else None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _guess_extension(ctype: str, body: bytes, url: str) -> str | None:
|
|
102
|
+
"""推断扩展名:Content-Type → 文件头 → URL 末段。"""
|
|
103
|
+
guess = mimetypes.guess_extension(ctype) if ctype else None
|
|
104
|
+
if guess in {None, ".bin", ".exe"}:
|
|
105
|
+
guess = sniff_ext(body)
|
|
106
|
+
if not guess:
|
|
107
|
+
tail = unquote(url.split("?")[0].rsplit("/", 1)[-1])
|
|
108
|
+
if "." in tail[-6:]:
|
|
109
|
+
guess = "." + tail.rsplit(".", 1)[-1].lower()
|
|
110
|
+
return guess
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _finalize_name(item: FileItem, headers, head: bytes, url: str) -> str:
|
|
114
|
+
"""由链接文本 / Content-Disposition / URL 推断并补全最终文件名。"""
|
|
115
|
+
name = (item.name or "").strip()
|
|
116
|
+
if not name or name.lower() in {"download", "untitled", "link"}:
|
|
117
|
+
name = _name_from_disposition(headers) or sanitize_filename(
|
|
118
|
+
unquote(url.split("?")[0].rsplit("/", 1)[-1]) or "file"
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
if not Path(name).suffix or Path(name).suffix.lower() in _UNKNOWN_SUFFIXES:
|
|
122
|
+
ctype = str(headers.get("content-type", "") or "").split(";")[0].strip()
|
|
123
|
+
guess = _guess_extension(ctype, head, url)
|
|
124
|
+
if guess:
|
|
125
|
+
base = name.rsplit(".", 1)[0] if Path(name).suffix else name
|
|
126
|
+
name = base + guess
|
|
127
|
+
|
|
128
|
+
return sanitize_filename(name)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _name_needs_sniff(name: str) -> bool:
|
|
132
|
+
"""文件名是否还缺一个可信扩展名(需要读文件头来嗅探)。"""
|
|
133
|
+
suffix = Path(name).suffix.lower()
|
|
134
|
+
return not suffix or suffix in _UNKNOWN_SUFFIXES
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def build_index(course_dir: Path) -> dict[str, Path]:
|
|
138
|
+
"""索引课程目录下已有文件,用于增量去重。
|
|
139
|
+
|
|
140
|
+
- 键 1:``文件名(小写)`` → 路径
|
|
141
|
+
- 键 2:``__size__<体积><后缀>`` → 路径(仅大文件,应对「同名不同内容/不同名同内容」)
|
|
142
|
+
|
|
143
|
+
``.part`` 等未完成的临时文件不参与索引,避免被误判成「已存在」。
|
|
144
|
+
"""
|
|
145
|
+
index: dict[str, Path] = {}
|
|
146
|
+
if not course_dir.exists():
|
|
147
|
+
return index
|
|
148
|
+
for path in course_dir.rglob("*"):
|
|
149
|
+
if not path.is_file() or ".git" in path.parts or ".venv" in path.parts:
|
|
150
|
+
continue
|
|
151
|
+
if path.name.endswith(PART_SUFFIX):
|
|
152
|
+
continue
|
|
153
|
+
index.setdefault(path.name.lower(), path)
|
|
154
|
+
try:
|
|
155
|
+
size = path.stat().st_size
|
|
156
|
+
except OSError:
|
|
157
|
+
continue
|
|
158
|
+
if size > SIZE_DEDUP_THRESHOLD:
|
|
159
|
+
index.setdefault(f"__size__{size}{path.suffix.lower()}", path)
|
|
160
|
+
return index
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _remember(index: dict[str, Path] | None, name: str, path: Path, size: int) -> None:
|
|
164
|
+
"""把本次新落盘的文件写回索引,供同一轮后续条目去重。"""
|
|
165
|
+
if index is None:
|
|
166
|
+
return
|
|
167
|
+
index.setdefault(name.lower(), path)
|
|
168
|
+
if size > SIZE_DEDUP_THRESHOLD:
|
|
169
|
+
index.setdefault(f"__size__{size}{path.suffix.lower()}", path)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _index_match(index: dict[str, Path] | None, name: str, size: int | None) -> Path | None:
|
|
173
|
+
"""按「文件名」或「体积+后缀」在索引里找已存在文件。"""
|
|
174
|
+
if not index:
|
|
175
|
+
return None
|
|
176
|
+
found = index.get(name.lower())
|
|
177
|
+
if found is not None:
|
|
178
|
+
return found
|
|
179
|
+
if size is not None and size > SIZE_DEDUP_THRESHOLD:
|
|
180
|
+
return index.get(f"__size__{size}{Path(name).suffix.lower()}")
|
|
181
|
+
return None
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _content_length(headers) -> int | None:
|
|
185
|
+
"""读取 ``Content-Length``;缺失或非法时返回 None。"""
|
|
186
|
+
raw = headers.get("content-length")
|
|
187
|
+
if raw is None:
|
|
188
|
+
return None
|
|
189
|
+
try:
|
|
190
|
+
return int(str(raw).strip())
|
|
191
|
+
except ValueError:
|
|
192
|
+
return None
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _category_dir(course_dir: Path, item: FileItem) -> Path:
|
|
196
|
+
"""条目落盘目录;作业按 hwN 归档,对齐已有目录习惯。"""
|
|
197
|
+
cat_dir = course_dir / item.category
|
|
198
|
+
if item.category == "assignments" and item.name:
|
|
199
|
+
m = HW_NUM.search(item.name)
|
|
200
|
+
if m:
|
|
201
|
+
cat_dir = cat_dir / f"hw{int(m.group(1))}"
|
|
202
|
+
return cat_dir
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _relpath(path: Path, course_dir: Path) -> str:
|
|
206
|
+
"""相对课程目录的展示路径;跨盘等取不到相对路径时退回文件名。"""
|
|
207
|
+
try:
|
|
208
|
+
return str(path.relative_to(course_dir)).replace("\\", "/")
|
|
209
|
+
except ValueError:
|
|
210
|
+
return path.name
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
class DownloadSession:
|
|
214
|
+
"""一次同步复用的下载器(浏览器原生通道 + 整块读取兜底)。
|
|
215
|
+
|
|
216
|
+
- ``probe`` 只用 HEAD 拿文件名/大小,命中已有文件时**完全不下载体**
|
|
217
|
+
- 需要新下载时优先用浏览器原生下载,由 Chrome 直接流式写盘
|
|
218
|
+
- 浏览器下载在本轮首次失败后熔断,后续文件直接走整块读取,不再重复等待超时
|
|
219
|
+
"""
|
|
220
|
+
|
|
221
|
+
def __init__(self, page, request_ctx) -> None:
|
|
222
|
+
self.page = page
|
|
223
|
+
self.request = request_ctx
|
|
224
|
+
self.browser_download_failed = False
|
|
225
|
+
|
|
226
|
+
def probe(self, item: FileItem) -> tuple[str, int | None]:
|
|
227
|
+
"""探测文件名与大小:HEAD 优先,名字缺扩展名时才补读文件头。"""
|
|
228
|
+
headers: dict = {}
|
|
229
|
+
try:
|
|
230
|
+
resp = self.request.fetch(item.url, method="HEAD", timeout=PROBE_TIMEOUT_MS)
|
|
231
|
+
if resp.status < 400:
|
|
232
|
+
headers = dict(resp.headers)
|
|
233
|
+
except Exception:
|
|
234
|
+
headers = {}
|
|
235
|
+
|
|
236
|
+
head = b""
|
|
237
|
+
name = _finalize_name(item, headers, head, item.url)
|
|
238
|
+
if _name_needs_sniff(name):
|
|
239
|
+
head = self._peek_head(item.url)
|
|
240
|
+
if head:
|
|
241
|
+
name = _finalize_name(item, headers, head, item.url)
|
|
242
|
+
return name, _content_length(headers)
|
|
243
|
+
|
|
244
|
+
def _peek_head(self, url: str) -> bytes:
|
|
245
|
+
"""读取文件头用于嗅探;服务端支持 Range 时只取几 KB。"""
|
|
246
|
+
try:
|
|
247
|
+
resp = self.request.get(
|
|
248
|
+
url, headers={"Range": f"bytes=0-{SNIFF_BYTES - 1}"}, timeout=PROBE_TIMEOUT_MS
|
|
249
|
+
)
|
|
250
|
+
return resp.body()[:SNIFF_BYTES]
|
|
251
|
+
except Exception:
|
|
252
|
+
return b""
|
|
253
|
+
|
|
254
|
+
def download_item(
|
|
255
|
+
self,
|
|
256
|
+
item: FileItem,
|
|
257
|
+
course_dir: Path,
|
|
258
|
+
dry_run: bool,
|
|
259
|
+
index: dict[str, Path] | None = None,
|
|
260
|
+
) -> tuple[str, str]:
|
|
261
|
+
"""下载单个条目,返回 ``(状态, 名称)``。
|
|
262
|
+
|
|
263
|
+
状态取值:``downloaded`` / ``exists`` / ``would-download`` / ``empty``。
|
|
264
|
+
"""
|
|
265
|
+
cat_dir = _category_dir(course_dir, item)
|
|
266
|
+
name, size = self.probe(item)
|
|
267
|
+
|
|
268
|
+
already = _index_match(index, name, size)
|
|
269
|
+
if already is not None:
|
|
270
|
+
return ("exists", _relpath(already, course_dir))
|
|
271
|
+
target = cat_dir / name
|
|
272
|
+
if target.exists():
|
|
273
|
+
return ("exists", target.name)
|
|
274
|
+
if dry_run:
|
|
275
|
+
return ("would-download", name)
|
|
276
|
+
if size == 0:
|
|
277
|
+
return ("empty", name)
|
|
278
|
+
|
|
279
|
+
part = target.with_name(target.name + PART_SUFFIX)
|
|
280
|
+
part.parent.mkdir(parents=True, exist_ok=True)
|
|
281
|
+
with contextlib.suppress(OSError):
|
|
282
|
+
part.unlink()
|
|
283
|
+
|
|
284
|
+
if not self._fetch_body(item.url, part):
|
|
285
|
+
raise NetworkError(f"下载失败:{name}", "检查网络或登录态后重试 bb-sync run")
|
|
286
|
+
|
|
287
|
+
written = part.stat().st_size
|
|
288
|
+
if written == 0:
|
|
289
|
+
with contextlib.suppress(OSError):
|
|
290
|
+
part.unlink()
|
|
291
|
+
return ("empty", name)
|
|
292
|
+
if size is not None and written != size:
|
|
293
|
+
with contextlib.suppress(OSError):
|
|
294
|
+
part.unlink()
|
|
295
|
+
raise NetworkError(
|
|
296
|
+
f"下载不完整:{name}(预期 {size} 字节,实际 {written} 字节)",
|
|
297
|
+
"文件未落盘,重试 bb-sync run 即可",
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
os.replace(part, target)
|
|
301
|
+
_remember(index, name, target, written)
|
|
302
|
+
return ("downloaded", name)
|
|
303
|
+
|
|
304
|
+
def _fetch_body(self, url: str, part: Path) -> bool:
|
|
305
|
+
"""取回文件体:优先浏览器原生下载,失败后本轮改用整块读取。"""
|
|
306
|
+
if not self.browser_download_failed:
|
|
307
|
+
if self._try_browser_download(url, part):
|
|
308
|
+
return True
|
|
309
|
+
self.browser_download_failed = True
|
|
310
|
+
return self._try_buffered(url, part)
|
|
311
|
+
|
|
312
|
+
def _try_browser_download(self, url: str, part: Path) -> bool:
|
|
313
|
+
"""用页面里的 ``<a download>`` 触发浏览器下载,由 Chrome 流式写盘。"""
|
|
314
|
+
try:
|
|
315
|
+
with self.page.expect_download(timeout=DOWNLOAD_START_TIMEOUT_MS) as info:
|
|
316
|
+
self.page.evaluate(_TRIGGER_DOWNLOAD_JS, url)
|
|
317
|
+
download = info.value
|
|
318
|
+
download.save_as(str(part))
|
|
319
|
+
if download.failure() or not part.exists():
|
|
320
|
+
raise OSError("浏览器下载未完成")
|
|
321
|
+
return True
|
|
322
|
+
except Exception:
|
|
323
|
+
with contextlib.suppress(OSError):
|
|
324
|
+
part.unlink()
|
|
325
|
+
return False
|
|
326
|
+
|
|
327
|
+
def _try_buffered(self, url: str, part: Path) -> bool:
|
|
328
|
+
"""兜底:用请求上下文整块读取后写盘。"""
|
|
329
|
+
try:
|
|
330
|
+
resp = self.request.get(url, timeout=BUFFERED_TIMEOUT_MS)
|
|
331
|
+
part.write_bytes(resp.body())
|
|
332
|
+
except Exception:
|
|
333
|
+
return False
|
|
334
|
+
return True
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
__all__ = [
|
|
338
|
+
"BUFFERED_TIMEOUT_MS",
|
|
339
|
+
"DOWNLOAD_START_TIMEOUT_MS",
|
|
340
|
+
"DownloadSession",
|
|
341
|
+
"HW_NUM",
|
|
342
|
+
"PART_SUFFIX",
|
|
343
|
+
"PROBE_TIMEOUT_MS",
|
|
344
|
+
"SIZE_DEDUP_THRESHOLD",
|
|
345
|
+
"SNIFF_BYTES",
|
|
346
|
+
"build_index",
|
|
347
|
+
"sniff_ext",
|
|
348
|
+
]
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""ADFS SSO 登录与登录态维护。
|
|
2
|
+
|
|
3
|
+
Blackboard 的登录按钮跳到 ADFS(STS)OAuth2;ADFS 登录页是**分页式**的:
|
|
4
|
+
第一页填账号 → 点「下一步」→ 第二页填密码 → 点「登录」。少数配置会显示在同一页,
|
|
5
|
+
所以这里两种形态都兼容。
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import contextlib
|
|
11
|
+
import getpass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from playwright.sync_api import Page
|
|
15
|
+
from playwright.sync_api import TimeoutError as PWTimeout
|
|
16
|
+
|
|
17
|
+
from bb_sync import ISSUES_URL, paths
|
|
18
|
+
from bb_sync.blackboard.models import (
|
|
19
|
+
_PORTAL_HOME,
|
|
20
|
+
ADFS_NEXT,
|
|
21
|
+
ADFS_PASS,
|
|
22
|
+
ADFS_SUBMIT,
|
|
23
|
+
ADFS_USER,
|
|
24
|
+
LOGIN_URL,
|
|
25
|
+
)
|
|
26
|
+
from bb_sync.core.errors import AuthError
|
|
27
|
+
from bb_sync.core.output import Console
|
|
28
|
+
from bb_sync.creds import Credentials, load_credentials, save_credentials
|
|
29
|
+
|
|
30
|
+
#: Blackboard 主机名;用于判断当前页面是否已回到站点
|
|
31
|
+
BB_HOST = "bb.cuhk.edu.cn"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _on_blackboard(page: Page) -> bool:
|
|
35
|
+
"""当前是否已停在 Blackboard(而非登录页 / ADFS / STS)。"""
|
|
36
|
+
url = page.url.lower()
|
|
37
|
+
return BB_HOST in url and not any(marker in url for marker in ("login", "adfs", "sts."))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def is_logged_in(page: Page) -> bool:
|
|
41
|
+
"""访问门户首页,判断登录态是否有效。
|
|
42
|
+
|
|
43
|
+
判定依据是「有没有被踢回登录 / ADFS 页」,而不是页面文案:门户首页正文由 JS
|
|
44
|
+
异步渲染,按 ``Courses`` / ``课程`` 等关键词判断会在**已登录时误报未登录**(实测踩到),
|
|
45
|
+
导致多跑一遍登录流程、点完登录按钮又回到门户,最后卡在 ADFS 选择器上。
|
|
46
|
+
"""
|
|
47
|
+
try:
|
|
48
|
+
page.goto(_PORTAL_HOME, wait_until="domcontentloaded", timeout=30000)
|
|
49
|
+
page.wait_for_timeout(2000)
|
|
50
|
+
return _on_blackboard(page)
|
|
51
|
+
except PWTimeout:
|
|
52
|
+
return False
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _safe_url(page: Page) -> str:
|
|
56
|
+
"""取当前 URL;页面已关闭等情况下退回占位文本,保证报错本身不二次失败。"""
|
|
57
|
+
try:
|
|
58
|
+
return page.url
|
|
59
|
+
except Exception:
|
|
60
|
+
return "(URL 不可用)"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _dump_login_debug(page: Page) -> Path:
|
|
64
|
+
"""保存登录失败现场(截图 + 页面源码),返回产物目录;写盘失败不中断报错。"""
|
|
65
|
+
home = paths.BB_SYNC_HOME
|
|
66
|
+
with contextlib.suppress(OSError):
|
|
67
|
+
home.mkdir(parents=True, exist_ok=True)
|
|
68
|
+
with contextlib.suppress(Exception):
|
|
69
|
+
page.screenshot(path=str(home / "debug_login.png"), full_page=True, timeout=5000)
|
|
70
|
+
with contextlib.suppress(Exception):
|
|
71
|
+
(home / "debug_login.html").write_text(page.content(), encoding="utf-8")
|
|
72
|
+
return home
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def do_login(page: Page, username: str, password: str) -> None:
|
|
76
|
+
"""执行一次完整登录;**任何一步**失败都保存调试产物并抛 :class:`AuthError`。
|
|
77
|
+
|
|
78
|
+
登录链路上每一步都可能因站点改版、MFA、网络或选择器漂移而卡住。这里统一兜底:
|
|
79
|
+
把当前 URL 写进错误信息,并落一份截图 + HTML,避免退化成「未预期错误 + 堆栈」
|
|
80
|
+
这种既没有可操作提示、又没有现场可查的形态。
|
|
81
|
+
"""
|
|
82
|
+
try:
|
|
83
|
+
_perform_login(page, username, password)
|
|
84
|
+
except Exception as exc:
|
|
85
|
+
home = _dump_login_debug(page)
|
|
86
|
+
detail = str(exc).strip() or exc.__class__.__name__
|
|
87
|
+
raise AuthError(
|
|
88
|
+
f"登录失败(当前 URL: {_safe_url(page)}):{detail}",
|
|
89
|
+
f"已保存登录现场到 {home}(debug_login.png / debug_login.html)。"
|
|
90
|
+
"若页面停在 ADFS / MFA / 验证码,请用 bb-sync run --headed 手动完成;"
|
|
91
|
+
f"若怀疑是登录流程改版或 bb-sync 的问题,欢迎到 {ISSUES_URL} 提 issue,"
|
|
92
|
+
"并附上 debug_login.html(以及上面这行 URL)。",
|
|
93
|
+
) from exc
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _perform_login(page: Page, username: str, password: str) -> None:
|
|
97
|
+
"""实际登录流程(由 :func:`do_login` 统一兜底与落现场)。"""
|
|
98
|
+
page.goto(LOGIN_URL, wait_until="domcontentloaded")
|
|
99
|
+
# 主按钮跳转 ADFS OAuth2
|
|
100
|
+
page.wait_for_selector("input[name='login'], #login input.submit", timeout=10000)
|
|
101
|
+
with page.expect_navigation(wait_until="domcontentloaded", timeout=30000):
|
|
102
|
+
page.click("input[name='login']")
|
|
103
|
+
|
|
104
|
+
# 点「登录」后有两种走向:
|
|
105
|
+
# 1) SSO 已生效 → 直接回到 BB 门户(登录态有效却被误判为未登录时会走这里)
|
|
106
|
+
# 2) 未登录 → 跳 ADFS,需要填账号 / 密码
|
|
107
|
+
if not _on_blackboard(page):
|
|
108
|
+
# ADFS 登录页(分页式:先账号 → 提交 → 再密码;少数配置同页显示)
|
|
109
|
+
page.wait_for_selector(ADFS_USER, timeout=20000)
|
|
110
|
+
page.fill(ADFS_USER, username)
|
|
111
|
+
|
|
112
|
+
if not page.locator(ADFS_PASS).first.is_visible():
|
|
113
|
+
# 第一页只有账号:点「下一步」翻到密码页
|
|
114
|
+
try:
|
|
115
|
+
page.click(ADFS_NEXT, timeout=8000)
|
|
116
|
+
except PWTimeout:
|
|
117
|
+
page.press(ADFS_USER, "Enter")
|
|
118
|
+
page.wait_for_selector(ADFS_PASS, state="visible", timeout=20000)
|
|
119
|
+
|
|
120
|
+
page.fill(ADFS_PASS, password)
|
|
121
|
+
try:
|
|
122
|
+
with page.expect_navigation(wait_until="domcontentloaded", timeout=30000):
|
|
123
|
+
page.click(ADFS_SUBMIT, timeout=8000)
|
|
124
|
+
except PWTimeout:
|
|
125
|
+
page.press(ADFS_PASS, "Enter")
|
|
126
|
+
page.wait_for_load_state("domcontentloaded", timeout=20000)
|
|
127
|
+
|
|
128
|
+
# 等待回到 Blackboard
|
|
129
|
+
for _ in range(30):
|
|
130
|
+
page.wait_for_timeout(1000)
|
|
131
|
+
if _on_blackboard(page):
|
|
132
|
+
break
|
|
133
|
+
|
|
134
|
+
if not is_logged_in(page):
|
|
135
|
+
raise AuthError("已填写凭据,但未回到 Blackboard(可能是账号/密码错误,或需要 MFA)")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def prompt_credentials(console: Console) -> Credentials:
|
|
139
|
+
"""交互式录入凭据并保存到系统钥匙串(密码不回显)。"""
|
|
140
|
+
console.step("[login] 未找到已保存的凭据,请录入(保存后无需重复输入)")
|
|
141
|
+
student_id = input("学号(学生)/ 邮箱前缀(教职工): ").strip()
|
|
142
|
+
if not student_id:
|
|
143
|
+
raise AuthError("学号不能为空")
|
|
144
|
+
password = getpass.getpass("密码(输入不回显): ")
|
|
145
|
+
if not password:
|
|
146
|
+
raise AuthError("密码不能为空")
|
|
147
|
+
backend = save_credentials(student_id, password)
|
|
148
|
+
console.success(f"凭据已保存到系统钥匙串({backend}),明文不再落盘")
|
|
149
|
+
return Credentials(student_id, password, "keyring")
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def resolve_credentials(console: Console) -> Credentials:
|
|
153
|
+
"""取得可用凭据:优先系统钥匙串,缺失时交互录入(并保存)。"""
|
|
154
|
+
creds = load_credentials()
|
|
155
|
+
if creds.ok:
|
|
156
|
+
console.log(f"[login] 凭据来源: {creds.source}")
|
|
157
|
+
return creds
|
|
158
|
+
return prompt_credentials(console)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def ensure_login(page: Page, console: Console, *, headed: bool = False) -> None:
|
|
162
|
+
"""确保处于登录态(复用 profile → 静默登录 → 有头模式等人工过 MFA)。"""
|
|
163
|
+
if is_logged_in(page):
|
|
164
|
+
console.log("[login] 已有有效登录态(复用 Steel profile)")
|
|
165
|
+
return
|
|
166
|
+
|
|
167
|
+
creds = resolve_credentials(console)
|
|
168
|
+
try:
|
|
169
|
+
do_login(page, creds.student_id, creds.password)
|
|
170
|
+
except AuthError:
|
|
171
|
+
if not headed:
|
|
172
|
+
raise # 静默模式失败就直接报错,提示改用 --headed
|
|
173
|
+
# 有头模式:可能需要人工过 MFA,留时间给用户在窗口里操作
|
|
174
|
+
console.step("[login] 等待人工完成 MFA / 验证码(最多 120 秒)...")
|
|
175
|
+
for _ in range(60):
|
|
176
|
+
page.wait_for_timeout(2000)
|
|
177
|
+
if is_logged_in(page):
|
|
178
|
+
console.success("检测到登录成功")
|
|
179
|
+
return
|
|
180
|
+
raise
|
|
181
|
+
console.success("登录成功")
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
__all__ = [
|
|
185
|
+
"do_login",
|
|
186
|
+
"ensure_login",
|
|
187
|
+
"is_logged_in",
|
|
188
|
+
"prompt_credentials",
|
|
189
|
+
"resolve_credentials",
|
|
190
|
+
]
|