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/core/update.py
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
"""检查 bb-sync 是否有新版本,并在落后时提示用户升级。
|
|
2
|
+
|
|
3
|
+
更新检查属于「锦上添花」的非关键路径,必须满足三条约束:
|
|
4
|
+
|
|
5
|
+
- **绝不阻塞/破坏主命令**:网络、缓存或 GitHub API 异常都静默忽略;
|
|
6
|
+
- **不频繁访问网络**:结果(含失败)缓存到 ``BB_SYNC_HOME/update-check.json``,
|
|
7
|
+
默认 24 小时内不重复检查;
|
|
8
|
+
- **不污染 stdout**:提示通过 :class:`~bb_sync.core.output.Console` 写 stderr,
|
|
9
|
+
``--json`` 的 stdout 仍然只有命令结果。
|
|
10
|
+
|
|
11
|
+
只在普通命令成功返回后调用本模块;``--help`` / ``--version`` / 无参数等早退路径
|
|
12
|
+
不会触发网络请求。
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import contextlib
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import re
|
|
21
|
+
import time
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import TYPE_CHECKING, Any
|
|
25
|
+
from urllib import error, request
|
|
26
|
+
|
|
27
|
+
from bb_sync import __version__, paths
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING: # 仅用于类型标注,运行时由 Console 自己惰性导入 rich
|
|
30
|
+
from bb_sync.core.output import Console
|
|
31
|
+
|
|
32
|
+
#: GitHub 最新 release API。仓库是 bb-sync 的唯一发布入口。
|
|
33
|
+
RELEASES_API = "https://api.github.com/repos/asdukw/cuhk-shenzhen-blackboard-sync/releases/latest"
|
|
34
|
+
|
|
35
|
+
#: 更新检查缓存文件(放在用户运行时目录,不写当前工作目录)。
|
|
36
|
+
CACHE_NAME = "update-check.json"
|
|
37
|
+
|
|
38
|
+
#: 同一版本检查结果的缓存时长:一天最多访问一次 GitHub。
|
|
39
|
+
CACHE_TTL_SECONDS = 24 * 60 * 60
|
|
40
|
+
|
|
41
|
+
#: 单次 HTTP 请求超时;更新检查不应明显拖慢 CLI 退出。
|
|
42
|
+
REQUEST_TIMEOUT_SECONDS = 2.0
|
|
43
|
+
|
|
44
|
+
#: 设置该环境变量为非空值时关闭自动更新检查(主要供脚本 / CI 使用)。
|
|
45
|
+
DISABLE_ENV = "BB_SYNC_NO_UPDATE_CHECK"
|
|
46
|
+
|
|
47
|
+
_VERSION_RE = re.compile(r"^[vV]?(\d+(?:\.\d+)*)")
|
|
48
|
+
_SOURCE_DEV_VERSION = "0.0.0.dev0"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class ReleaseInfo:
|
|
53
|
+
"""GitHub 最新 release 的规范化信息。"""
|
|
54
|
+
|
|
55
|
+
version: str
|
|
56
|
+
tag: str
|
|
57
|
+
url: str
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True)
|
|
61
|
+
class UpdateInfo:
|
|
62
|
+
"""调用方需要展示的「当前版本 → 最新版本」信息。"""
|
|
63
|
+
|
|
64
|
+
current: str
|
|
65
|
+
latest: str
|
|
66
|
+
release_url: str
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _version_key(value: str) -> tuple[int, ...] | None:
|
|
70
|
+
"""把 ``v1.2.3`` 这类版本号转成可比较的数字元组。
|
|
71
|
+
|
|
72
|
+
只支持本项目使用的语义化数字版本;遇到无法识别的字符串时返回 ``None``,
|
|
73
|
+
让调用方安静跳过,而不是猜一个可能错误的比较结果。
|
|
74
|
+
"""
|
|
75
|
+
match = _VERSION_RE.match(value.strip())
|
|
76
|
+
if match is None:
|
|
77
|
+
return None
|
|
78
|
+
try:
|
|
79
|
+
return tuple(int(part) for part in match.group(1).split("."))
|
|
80
|
+
except ValueError: # pragma: no cover - 正则已限制为数字与点
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def is_outdated(current: str, latest: str) -> bool:
|
|
85
|
+
"""判断 ``current`` 是否严格落后于 ``latest``。"""
|
|
86
|
+
current_key = _version_key(current)
|
|
87
|
+
latest_key = _version_key(latest)
|
|
88
|
+
if current_key is None or latest_key is None:
|
|
89
|
+
return False
|
|
90
|
+
|
|
91
|
+
width = max(len(current_key), len(latest_key))
|
|
92
|
+
current_padded = current_key + (0,) * (width - len(current_key))
|
|
93
|
+
latest_padded = latest_key + (0,) * (width - len(latest_key))
|
|
94
|
+
return current_padded < latest_padded
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _normalize_version(value: str) -> str | None:
|
|
98
|
+
"""去掉版本号开头的 ``v``,并校验其格式。"""
|
|
99
|
+
normalized = value.strip()
|
|
100
|
+
if normalized[:1] in {"v", "V"}:
|
|
101
|
+
normalized = normalized[1:]
|
|
102
|
+
return normalized if _version_key(normalized) is not None else None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _release_url(version: str, tag: str | None = None) -> str:
|
|
106
|
+
"""GitHub release 页面地址;API 没给 html_url 时兜底生成。"""
|
|
107
|
+
return f"https://github.com/asdukw/cuhk-shenzhen-blackboard-sync/releases/tag/{tag or f'v{version}'}"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _disabled() -> bool:
|
|
111
|
+
"""是否通过环境变量关闭更新检查。"""
|
|
112
|
+
return bool(os.environ.get(DISABLE_ENV, "").strip())
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def fetch_latest_release(timeout: float = REQUEST_TIMEOUT_SECONDS) -> ReleaseInfo | None:
|
|
116
|
+
"""请求 GitHub 最新 release;任何失败都返回 ``None``。"""
|
|
117
|
+
req = request.Request(
|
|
118
|
+
RELEASES_API,
|
|
119
|
+
headers={
|
|
120
|
+
"Accept": "application/vnd.github+json",
|
|
121
|
+
"User-Agent": f"bb-sync/{__version__}",
|
|
122
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
123
|
+
},
|
|
124
|
+
)
|
|
125
|
+
try:
|
|
126
|
+
with request.urlopen(req, timeout=timeout) as response:
|
|
127
|
+
payload = json.load(response)
|
|
128
|
+
except (OSError, error.URLError, ValueError):
|
|
129
|
+
return None
|
|
130
|
+
|
|
131
|
+
if not isinstance(payload, dict):
|
|
132
|
+
return None
|
|
133
|
+
tag = payload.get("tag_name")
|
|
134
|
+
if not isinstance(tag, str):
|
|
135
|
+
return None
|
|
136
|
+
version = _normalize_version(tag)
|
|
137
|
+
if version is None:
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
html_url = payload.get("html_url")
|
|
141
|
+
if not isinstance(html_url, str) or not html_url:
|
|
142
|
+
html_url = _release_url(version, tag)
|
|
143
|
+
return ReleaseInfo(version=version, tag=tag, url=html_url)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _cache_path() -> Path:
|
|
147
|
+
"""缓存文件路径;每次调用都从 ``paths`` 读取,方便测试隔离。"""
|
|
148
|
+
return paths.BB_SYNC_HOME / CACHE_NAME
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _read_cache() -> dict[str, Any] | None:
|
|
152
|
+
"""读取缓存;文件缺失、损坏或类型不对时都视为没有缓存。"""
|
|
153
|
+
try:
|
|
154
|
+
payload = json.loads(_cache_path().read_text(encoding="utf-8"))
|
|
155
|
+
except (OSError, ValueError):
|
|
156
|
+
return None
|
|
157
|
+
return payload if isinstance(payload, dict) else None
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _write_cache(*, checked_at: float, release: ReleaseInfo | None) -> None:
|
|
161
|
+
"""原子写入缓存;写入失败不影响调用方。"""
|
|
162
|
+
payload: dict[str, Any] = {"checked_at": checked_at}
|
|
163
|
+
if release is None:
|
|
164
|
+
payload["latest_version"] = None
|
|
165
|
+
else:
|
|
166
|
+
payload.update(
|
|
167
|
+
{
|
|
168
|
+
"latest_version": release.version,
|
|
169
|
+
"tag_name": release.tag,
|
|
170
|
+
"release_url": release.url,
|
|
171
|
+
}
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
cache_path = _cache_path()
|
|
175
|
+
tmp_path = cache_path.with_suffix(cache_path.suffix + ".tmp")
|
|
176
|
+
try:
|
|
177
|
+
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
|
178
|
+
tmp_path.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
|
|
179
|
+
tmp_path.replace(cache_path)
|
|
180
|
+
except OSError:
|
|
181
|
+
# 用户目录只读等情况下仍允许主命令正常执行;下次再尝试检查。
|
|
182
|
+
with contextlib.suppress(OSError):
|
|
183
|
+
tmp_path.unlink(missing_ok=True)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _cache_release(payload: dict[str, Any]) -> ReleaseInfo | None:
|
|
187
|
+
"""从缓存 JSON 还原 release;非法字段按无缓存处理。"""
|
|
188
|
+
version = payload.get("latest_version")
|
|
189
|
+
if not isinstance(version, str):
|
|
190
|
+
return None
|
|
191
|
+
normalized = _normalize_version(version)
|
|
192
|
+
if normalized is None:
|
|
193
|
+
return None
|
|
194
|
+
|
|
195
|
+
tag = payload.get("tag_name")
|
|
196
|
+
if not isinstance(tag, str) or not tag:
|
|
197
|
+
tag = f"v{normalized}"
|
|
198
|
+
url = payload.get("release_url")
|
|
199
|
+
if not isinstance(url, str) or not url:
|
|
200
|
+
url = _release_url(normalized, tag)
|
|
201
|
+
return ReleaseInfo(version=normalized, tag=tag, url=url)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _cache_checked_at(payload: dict[str, Any]) -> float | None:
|
|
205
|
+
checked_at = payload.get("checked_at")
|
|
206
|
+
if not isinstance(checked_at, (int, float)):
|
|
207
|
+
return None
|
|
208
|
+
return float(checked_at)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def check_for_update(
|
|
212
|
+
*,
|
|
213
|
+
current: str | None = None,
|
|
214
|
+
now: float | None = None,
|
|
215
|
+
timeout: float = REQUEST_TIMEOUT_SECONDS,
|
|
216
|
+
) -> UpdateInfo | None:
|
|
217
|
+
"""返回更新信息;没有新版本、已禁用或检查失败时返回 ``None``。
|
|
218
|
+
|
|
219
|
+
``current`` / ``now`` / ``timeout`` 主要供测试注入;生产调用使用默认值。
|
|
220
|
+
"""
|
|
221
|
+
current_version = current or __version__
|
|
222
|
+
# 源码直跑或开发版不提示更新:版本号本身不代表一个可升级的用户安装。
|
|
223
|
+
if current_version == _SOURCE_DEV_VERSION or _disabled():
|
|
224
|
+
return None
|
|
225
|
+
|
|
226
|
+
checked_now = time.time() if now is None else now
|
|
227
|
+
cache = _read_cache()
|
|
228
|
+
if cache is not None:
|
|
229
|
+
cached_at = _cache_checked_at(cache)
|
|
230
|
+
cached_release = _cache_release(cache)
|
|
231
|
+
if cached_at is not None and 0 <= checked_now - cached_at < CACHE_TTL_SECONDS:
|
|
232
|
+
return _outdated_info(current_version, cached_release)
|
|
233
|
+
else:
|
|
234
|
+
cached_release = None
|
|
235
|
+
|
|
236
|
+
release = fetch_latest_release(timeout=timeout)
|
|
237
|
+
if release is None:
|
|
238
|
+
# 网络失败也写缓存,避免离线用户每次执行命令都等待超时;若之前拿到过
|
|
239
|
+
# 最新的版本号,则保留它,只更新检查时间(旧 release 不会倒退)。
|
|
240
|
+
_write_cache(checked_at=checked_now, release=cached_release)
|
|
241
|
+
return _outdated_info(current_version, cached_release)
|
|
242
|
+
|
|
243
|
+
_write_cache(checked_at=checked_now, release=release)
|
|
244
|
+
return _outdated_info(current_version, release)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _outdated_info(current: str, release: ReleaseInfo | None) -> UpdateInfo | None:
|
|
248
|
+
"""把内部 release 转换为调用方需要展示的更新信息。"""
|
|
249
|
+
if release is None or not is_outdated(current, release.version):
|
|
250
|
+
return None
|
|
251
|
+
return UpdateInfo(current=current, latest=release.version, release_url=release.url)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def notify_if_outdated(
|
|
255
|
+
console: Console,
|
|
256
|
+
*,
|
|
257
|
+
current: str | None = None,
|
|
258
|
+
now: float | None = None,
|
|
259
|
+
timeout: float = REQUEST_TIMEOUT_SECONDS,
|
|
260
|
+
) -> UpdateInfo | None:
|
|
261
|
+
"""检查并输出更新提示;异常一律吞掉,绝不改变主命令结果。"""
|
|
262
|
+
try:
|
|
263
|
+
info = check_for_update(current=current, now=now, timeout=timeout)
|
|
264
|
+
except Exception: # 更新检查是附加功能,不能影响正常命令
|
|
265
|
+
return None
|
|
266
|
+
|
|
267
|
+
if info is None:
|
|
268
|
+
return None
|
|
269
|
+
console.warn(f"bb-sync 有新版本:当前 {info.current},最新 {info.latest}")
|
|
270
|
+
console.hint(f"运行 uv tool upgrade bb-sync 更新;详情见 {info.release_url}")
|
|
271
|
+
return info
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
__all__ = [
|
|
275
|
+
"CACHE_NAME",
|
|
276
|
+
"CACHE_TTL_SECONDS",
|
|
277
|
+
"DISABLE_ENV",
|
|
278
|
+
"RELEASES_API",
|
|
279
|
+
"REQUEST_TIMEOUT_SECONDS",
|
|
280
|
+
"ReleaseInfo",
|
|
281
|
+
"UpdateInfo",
|
|
282
|
+
"check_for_update",
|
|
283
|
+
"fetch_latest_release",
|
|
284
|
+
"is_outdated",
|
|
285
|
+
"notify_if_outdated",
|
|
286
|
+
]
|
bb_sync/creds.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""凭据存储:仅使用系统钥匙串(keyring),不在任何文件里落明文。
|
|
2
|
+
|
|
3
|
+
- 钥匙串:Windows 凭据管理器 / macOS 钥匙串 / Linux Secret Service,
|
|
4
|
+
凭据以 JSON 存在服务 ``"bb-sync"`` 下,不落明文文件。
|
|
5
|
+
- 不再支持 ``.env`` 兜底:明文凭据容易误提交或泄漏,一律走钥匙串。
|
|
6
|
+
若钥匙串在无桌面环境不可用,请改用交互式录入以外的部署方式。
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
from typing import NamedTuple
|
|
13
|
+
|
|
14
|
+
import keyring
|
|
15
|
+
import keyring.errors
|
|
16
|
+
|
|
17
|
+
SERVICE = "bb-sync"
|
|
18
|
+
ACCOUNT = "credentials"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Credentials(NamedTuple):
|
|
22
|
+
student_id: str
|
|
23
|
+
password: str
|
|
24
|
+
source: str # 目前恒为 "keyring"
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def ok(self) -> bool:
|
|
28
|
+
"""两项都非空才算可用。"""
|
|
29
|
+
return bool(self.student_id and self.password)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def load_credentials() -> Credentials:
|
|
33
|
+
"""从系统钥匙串读取凭据;不存在或读取失败时返回空凭据。"""
|
|
34
|
+
try:
|
|
35
|
+
blob = keyring.get_password(SERVICE, ACCOUNT)
|
|
36
|
+
except keyring.errors.KeyringError:
|
|
37
|
+
blob = None
|
|
38
|
+
if not blob:
|
|
39
|
+
return Credentials("", "", "keyring")
|
|
40
|
+
try:
|
|
41
|
+
data = json.loads(blob)
|
|
42
|
+
except json.JSONDecodeError:
|
|
43
|
+
data = {}
|
|
44
|
+
return Credentials(data.get("student_id", ""), data.get("password", ""), "keyring")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def save_credentials(student_id: str, password: str) -> str:
|
|
48
|
+
"""凭据存入系统钥匙串,返回实际使用的后端名称。"""
|
|
49
|
+
keyring.set_password(
|
|
50
|
+
SERVICE,
|
|
51
|
+
ACCOUNT,
|
|
52
|
+
json.dumps({"student_id": student_id, "password": password}),
|
|
53
|
+
)
|
|
54
|
+
return str(keyring.get_keyring())
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def delete_credentials() -> bool:
|
|
58
|
+
"""从钥匙串删除凭据;不存在时返回 False。"""
|
|
59
|
+
if keyring.get_password(SERVICE, ACCOUNT) is None:
|
|
60
|
+
return False
|
|
61
|
+
keyring.delete_password(SERVICE, ACCOUNT)
|
|
62
|
+
return True
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def has_keyring_credentials() -> bool:
|
|
66
|
+
"""钥匙串里是否已有凭据(不读取明文内容)。"""
|
|
67
|
+
try:
|
|
68
|
+
return keyring.get_password(SERVICE, ACCOUNT) is not None
|
|
69
|
+
except keyring.errors.KeyringError:
|
|
70
|
+
return False
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def mask(secret: str, tail: int = 4) -> str:
|
|
74
|
+
"""脱敏显示:只保留尾部若干位,其余用 * 替代。"""
|
|
75
|
+
if not secret:
|
|
76
|
+
return ""
|
|
77
|
+
if len(secret) <= tail:
|
|
78
|
+
return "*" * len(secret)
|
|
79
|
+
return "*" * (len(secret) - tail) + secret[-tail:]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
__all__ = [
|
|
83
|
+
"ACCOUNT",
|
|
84
|
+
"SERVICE",
|
|
85
|
+
"Credentials",
|
|
86
|
+
"delete_credentials",
|
|
87
|
+
"has_keyring_credentials",
|
|
88
|
+
"load_credentials",
|
|
89
|
+
"mask",
|
|
90
|
+
"save_credentials",
|
|
91
|
+
]
|
bb_sync/paths.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""路径解析:兼容「源码运行」与「安装为 CLI」两种形态。
|
|
2
|
+
|
|
3
|
+
用户文件(config.yaml)查找顺序:当前工作目录优先(项目内用法),
|
|
4
|
+
其次 ~/.bb-sync/(安装后的用户配置目录)。运行时目录(Steel、浏览器
|
|
5
|
+
profile、日志、调试产物)统一放 ~/.bb-sync/,可用环境变量 BB_SYNC_HOME 覆盖。
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
BB_SYNC_HOME = Path(os.environ.get("BB_SYNC_HOME") or Path.home() / ".bb-sync")
|
|
14
|
+
|
|
15
|
+
CONFIG_NAME = "config.yaml"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def find_config_file(name: str = CONFIG_NAME) -> Path:
|
|
19
|
+
"""查找用户配置文件:当前工作目录优先,其次 ~/.bb-sync/。"""
|
|
20
|
+
cwd_candidate = Path.cwd() / name
|
|
21
|
+
if cwd_candidate.exists():
|
|
22
|
+
return cwd_candidate
|
|
23
|
+
return BB_SYNC_HOME / name
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
__all__ = ["BB_SYNC_HOME", "CONFIG_NAME", "find_config_file"]
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: bb-sync
|
|
3
|
+
Version: 1.9.0
|
|
4
|
+
Summary: 港中深(CUHK-Shenzhen)Blackboard 课件下载与课程资源自动同步:登录 ADFS SSO,增量下载课件/作业/指导、公告与待办
|
|
5
|
+
Project-URL: Homepage, https://github.com/asdukw/cuhk-shenzhen-blackboard-sync
|
|
6
|
+
Project-URL: Repository, https://github.com/asdukw/cuhk-shenzhen-blackboard-sync
|
|
7
|
+
Project-URL: Issues, https://github.com/asdukw/cuhk-shenzhen-blackboard-sync/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/asdukw/cuhk-shenzhen-blackboard-sync/blob/main/CHANGELOG.md
|
|
9
|
+
License: MIT License
|
|
10
|
+
|
|
11
|
+
Copyright (c) 2026 bb-sync contributors
|
|
12
|
+
|
|
13
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
14
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
15
|
+
in the Software without restriction, including without limitation the rights
|
|
16
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
17
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
18
|
+
furnished to do so, subject to the following conditions:
|
|
19
|
+
|
|
20
|
+
The above copyright notice and this permission notice shall be included in all
|
|
21
|
+
copies or substantial portions of the Software.
|
|
22
|
+
|
|
23
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
24
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
25
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
26
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
27
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
28
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
29
|
+
SOFTWARE.
|
|
30
|
+
License-File: LICENSE
|
|
31
|
+
Keywords: adfs,blackboard,cli,course,course-download,cuhk-shenzhen,cuhksz,playwright,scraper,sync
|
|
32
|
+
Classifier: Environment :: Console
|
|
33
|
+
Classifier: Intended Audience :: Education
|
|
34
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
35
|
+
Classifier: Operating System :: MacOS
|
|
36
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
37
|
+
Classifier: Programming Language :: Python :: 3
|
|
38
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
39
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
40
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
41
|
+
Classifier: Topic :: Education
|
|
42
|
+
Requires-Python: >=3.11
|
|
43
|
+
Requires-Dist: click>=8.5.0
|
|
44
|
+
Requires-Dist: keyring>=25.7.0
|
|
45
|
+
Requires-Dist: playwright>=1.45
|
|
46
|
+
Requires-Dist: pydantic>=2.7
|
|
47
|
+
Requires-Dist: pyyaml>=6.0
|
|
48
|
+
Requires-Dist: rich>=13.7
|
|
49
|
+
Requires-Dist: typer>=0.12
|
|
50
|
+
Description-Content-Type: text/markdown
|
|
51
|
+
|
|
52
|
+
# bb-sync
|
|
53
|
+
|
|
54
|
+
**港中深(香港中文大学·深圳 / CUHK-Shenzhen / CUHKSZ)Blackboard 课件下载与课程资源自动同步 CLI**
|
|
55
|
+
|
|
56
|
+
登录 `bb.cuhk.edu.cn`,把课件、作业和指导增量下载到本地,把公告和全部课程的待办整理成 Markdown。重复运行时会跳过已经下载的文件。
|
|
57
|
+
|
|
58
|
+
*Blackboard course material sync CLI for CUHK-Shenzhen students.*
|
|
59
|
+
|
|
60
|
+
[](https://github.com/asdukw/cuhk-shenzhen-blackboard-sync/actions/workflows/ci.yml)
|
|
61
|
+
[](https://github.com/asdukw/cuhk-shenzhen-blackboard-sync/releases)
|
|
62
|
+
[](pyproject.toml)
|
|
63
|
+
[](LICENSE)
|
|
64
|
+
[](#安装)
|
|
65
|
+
|
|
66
|
+
> **适用边界**:只适配香港中文大学(深圳)的 `bb.cuhk.edu.cn` 和它的 ADFS SSO 登录流程。
|
|
67
|
+
> 其他学校、其他 Blackboard 站点请勿安装。支持 Windows 10/11(PowerShell)与 macOS(Intel / Apple Silicon,zsh / bash)。
|
|
68
|
+
|
|
69
|
+
## 它解决什么问题
|
|
70
|
+
|
|
71
|
+
Blackboard 上的东西散在「课程内容」「作业」「公告」几个入口,一学期下来几十上百个文件,靠手动点、手动记截止日期很容易漏。bb-sync 把这些收进本地一个目录:
|
|
72
|
+
|
|
73
|
+
- 课件、作业、指导按课程归好类,重跑只下新增的;
|
|
74
|
+
- 每门课的公告存成一份 `announcements.md`;
|
|
75
|
+
- 全部课程的待办汇总成一份 `due.md`,按「已逾期 → 今天 → 明天 → 未来 7 天 → 之后」排好序,公告里带日期的课堂测验和考试也一并计入。
|
|
76
|
+
|
|
77
|
+
它**不做**这些:不回传作业、不代签到、不抓成绩、不绕过任何权限——看到的就是你账号里本来能看到的。
|
|
78
|
+
|
|
79
|
+
## 长什么样
|
|
80
|
+
|
|
81
|
+
下面是示例输出(课程名和文件名为虚构,链接已缩写为 `…`,实际每条都带回到对应 Blackboard 课程页的链接)。
|
|
82
|
+
|
|
83
|
+
`due.md`:
|
|
84
|
+
|
|
85
|
+
````markdown
|
|
86
|
+
# Blackboard 待办
|
|
87
|
+
|
|
88
|
+
> 更新时间:2026-10-14 21:03(本地时间)
|
|
89
|
+
> 已检查 5 门课程,发现 7 项待办;按优先级和截止日期排列。
|
|
90
|
+
> 其中公告里的测验 1 项、考试 1 项已一并作为待办列出。
|
|
91
|
+
> 另有 1 项公告里的测验/考试日期已过,列在最后仅供回看。
|
|
92
|
+
|
|
93
|
+
## 已逾期(1)
|
|
94
|
+
|
|
95
|
+
- **2026-10-12**(逾期 2 天) — [CSC5010](https://bb.cuhk.edu.cn/…) — 作业 3 实验报告
|
|
96
|
+
|
|
97
|
+
## 今天(1)
|
|
98
|
+
|
|
99
|
+
- **2026-10-14**(今天) — [MAT1001](https://bb.cuhk.edu.cn/…) — 习题 5
|
|
100
|
+
|
|
101
|
+
## 明天(1)
|
|
102
|
+
|
|
103
|
+
- **2026-10-15**(明天) — [CSC5010](https://bb.cuhk.edu.cn/…) — 【测验】Quiz 2
|
|
104
|
+
|
|
105
|
+
## 未来 7 天(3)
|
|
106
|
+
|
|
107
|
+
- **2026-10-17**(还有 3 天) — [ENG1001](https://bb.cuhk.edu.cn/…) — Essay 1 初稿
|
|
108
|
+
- **2026-10-20**(还有 6 天) — [CSC5010](https://bb.cuhk.edu.cn/…) — 作业 4
|
|
109
|
+
- **2026-10-21**(还有 7 天) — [MAT1001](https://bb.cuhk.edu.cn/…) — 【考试】期中考试
|
|
110
|
+
|
|
111
|
+
## 已过期事件(1)
|
|
112
|
+
|
|
113
|
+
- **2026-10-08**(已过 6 天) — [CSC5010](https://bb.cuhk.edu.cn/…) — 【测验】Quiz 1
|
|
114
|
+
````
|
|
115
|
+
|
|
116
|
+
下载目录:
|
|
117
|
+
|
|
118
|
+
```text
|
|
119
|
+
~/courses/
|
|
120
|
+
├── CSC5010_人工智能/
|
|
121
|
+
│ ├── lectures/
|
|
122
|
+
│ │ ├── 01 绪论.pdf
|
|
123
|
+
│ │ └── 02 搜索算法.pdf
|
|
124
|
+
│ ├── assignments/
|
|
125
|
+
│ │ └── hw1/
|
|
126
|
+
│ │ ├── hw1.pdf
|
|
127
|
+
│ │ └── starter.zip
|
|
128
|
+
│ ├── tutorials/
|
|
129
|
+
│ │ └── tutorial01.ipynb
|
|
130
|
+
│ └── announcements.md
|
|
131
|
+
├── MAT1001_微积分/
|
|
132
|
+
│ └── ...
|
|
133
|
+
└── due.md
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
## 安装
|
|
137
|
+
|
|
138
|
+
需要 Git、Node.js 22+(含 npm)、uv 和 Chrome 或 Edge。bb-sync 会自动探测常见安装位置。
|
|
139
|
+
|
|
140
|
+
### Windows
|
|
141
|
+
|
|
142
|
+
在 PowerShell 中执行:
|
|
143
|
+
|
|
144
|
+
```powershell
|
|
145
|
+
winget install --id Git.Git -e
|
|
146
|
+
winget install --id OpenJS.NodeJS.LTS -e
|
|
147
|
+
powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Windows 自带的 Edge 即可使用;也可以自行安装 Google Chrome。
|
|
151
|
+
|
|
152
|
+
### macOS
|
|
153
|
+
|
|
154
|
+
推荐先安装 [Homebrew](https://brew.sh/),再执行:
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
brew install git node uv
|
|
158
|
+
brew install --cask google-chrome
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
如果已经安装 Chrome 或 Edge,可跳过最后一条。Edge 的 cask 名称为 `microsoft-edge`。
|
|
162
|
+
|
|
163
|
+
### 安装 bb-sync(Windows / macOS 相同)
|
|
164
|
+
|
|
165
|
+
**重开终端**后执行:
|
|
166
|
+
|
|
167
|
+
```bash
|
|
168
|
+
uv tool install bb-sync
|
|
169
|
+
bb-sync doctor
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
也可以直接从 GitHub 安装(跟随 `main` 上的最新代码):
|
|
173
|
+
|
|
174
|
+
```bash
|
|
175
|
+
uv tool install git+https://github.com/asdukw/cuhk-shenzhen-blackboard-sync
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
`bb-sync doctor` 用于检查环境;刚安装时凭据或浏览器后端显示“未配置”是正常的。如果提示找不到 `bb-sync`,执行 `uv tool update-shell` 后重开终端。校外使用 Blackboard 时,可能需要先连接学校 VPN。
|
|
179
|
+
|
|
180
|
+
## 首次使用
|
|
181
|
+
|
|
182
|
+
保存学号和密码:
|
|
183
|
+
|
|
184
|
+
```bash
|
|
185
|
+
bb-sync auth login
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
密码会保存在 Windows 凭据管理器或 macOS 钥匙串,不会写入配置文件。macOS 首次访问时可能弹出钥匙串授权提示,选择允许即可。
|
|
189
|
+
|
|
190
|
+
第一次同步请显示浏览器,便于完成登录、MFA 或验证码:
|
|
191
|
+
|
|
192
|
+
```bash
|
|
193
|
+
bb-sync run --headed
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
首次运行会自动准备浏览器后端,可能需要几分钟。保持网络连接,不要提前关闭终端。之后同步直接运行:
|
|
197
|
+
|
|
198
|
+
```bash
|
|
199
|
+
bb-sync run
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
## 文件位置
|
|
203
|
+
|
|
204
|
+
默认下载到用户主目录下的 `courses`:
|
|
205
|
+
|
|
206
|
+
- Windows:`C:\Users\<你>\courses`
|
|
207
|
+
- macOS:`/Users/<你>/courses`
|
|
208
|
+
|
|
209
|
+
每门课一个文件夹:
|
|
210
|
+
|
|
211
|
+
- 课件 → `lectures/`
|
|
212
|
+
- 作业 → `assignments/`
|
|
213
|
+
- 指导 / 实验 → `tutorials/`
|
|
214
|
+
- 公告 → `announcements.md`
|
|
215
|
+
- 全部课程待办 → `due.md`(按优先级排列;公告里带日期的课堂测验与考试也计入,日期已过的归入最后的「已过期事件」并保留 7 天,没有待办时显示 Congratulations)
|
|
216
|
+
|
|
217
|
+
要修改下载目录:
|
|
218
|
+
|
|
219
|
+
```bash
|
|
220
|
+
bb-sync config set root "~/University/courses"
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
Windows 也可以使用 `bb-sync config set root "D:/University/courses"`。更完整的配置说明见 [docs/config.md](docs/config.md)。
|
|
224
|
+
|
|
225
|
+
## 常用命令
|
|
226
|
+
|
|
227
|
+
| 命令 | 作用 |
|
|
228
|
+
| --- | --- |
|
|
229
|
+
| `bb-sync run` | 增量同步全部课程并更新 `due.md` |
|
|
230
|
+
| `bb-sync run --headed` | 显示浏览器,适合首次登录或 MFA |
|
|
231
|
+
| `bb-sync run --course CSC5010` | 只同步指定课程,可重复使用 |
|
|
232
|
+
| `bb-sync run --dry-run` | 预览下载和待办,不写文件 |
|
|
233
|
+
| `bb-sync due` | 扫描全部课程主页的 Due / To Do 与公告测验、考试,生成 `due.md` |
|
|
234
|
+
| `bb-sync due --course CSC5010` | 只整理指定课程的待办 |
|
|
235
|
+
| `bb-sync course list` | 列出账号下的课程 |
|
|
236
|
+
| `bb-sync auth login` / `logout` / `status` | 录入、删除、查看已保存的凭据 |
|
|
237
|
+
| `bb-sync config show` | 显示配置文件内容 |
|
|
238
|
+
| `bb-sync config set <键> <值>` | 修改配置;另有 `get` / `unset` / `edit` / `path` / `init` |
|
|
239
|
+
| `bb-sync doctor` | 检查环境是否就绪 |
|
|
240
|
+
| `bb-sync --help` | 查看完整帮助 |
|
|
241
|
+
|
|
242
|
+
## 遇到问题
|
|
243
|
+
|
|
244
|
+
- **`bb-sync` 找不到**:重开终端,或执行 `uv tool update-shell`。
|
|
245
|
+
- **环境检查不通过**:按 `bb-sync doctor` 的提示安装或配置缺失项。
|
|
246
|
+
- **第一次运行很慢**:首次需要准备浏览器后端,耐心等待。
|
|
247
|
+
- **登录失败或需要 MFA**:运行 `bb-sync auth status` 查看凭据,再用 `bb-sync run --headed` 重试。
|
|
248
|
+
- **更换密码**:重新运行 `bb-sync auth login`。
|
|
249
|
+
|
|
250
|
+
更多问题欢迎[开 Issue](https://github.com/asdukw/cuhk-shenzhen-blackboard-sync/issues)。
|
|
251
|
+
|
|
252
|
+
## 文档
|
|
253
|
+
|
|
254
|
+
- [docs/config.md](docs/config.md) —— 配置文件、目录映射与全部配置项
|
|
255
|
+
- [docs/scripting.md](docs/scripting.md) —— `--json` 输出结构与退出码,方便接脚本
|
|
256
|
+
- [CONTRIBUTING.md](CONTRIBUTING.md) —— 开发流程与质量门禁
|
|
257
|
+
- [CHANGELOG.md](CHANGELOG.md) —— 版本变更记录
|
|
258
|
+
|
|
259
|
+
## 免责声明
|
|
260
|
+
|
|
261
|
+
本工具只读取你自己账号里本来就能看到的内容,登录凭据保存在本机系统钥匙串中,不会上传到任何第三方。命令成功后会请求一次 GitHub Releases API 检查新版本,只发送 `bb-sync/<版本号>` 作为 User-Agent,不携带任何个人信息;设置环境变量 `BB_SYNC_NO_UPDATE_CHECK=1` 可关闭该检查。课程资料版权归香港中文大学(深圳)及各任课教师所有,**仅供个人学习使用,请勿二次分发**。使用本工具产生的一切后果由使用者自行承担。
|
|
262
|
+
|
|
263
|
+
## License
|
|
264
|
+
|
|
265
|
+
[MIT](LICENSE)
|