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.
@@ -0,0 +1,446 @@
1
+ """同步服务:编排「登录 → 列课 → 抓内容 → 下载」的完整流程。
2
+
3
+ 这一层是 CLI 与站点抓取之间的胶水,负责:
4
+ - 把 :class:`~bb_sync.core.config.Settings` 转成具体执行参数
5
+ - 驱动 Steel 会话的生命周期(起服务、建会话、接入 CDP、释放)
6
+ - 逐课程推进并把结果汇总成 :class:`SyncStats`
7
+
8
+ 本层不直接 ``print``:全部通过注入的 :class:`~bb_sync.core.output.Console`。
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import sys
14
+ from dataclasses import dataclass, field
15
+ from datetime import date
16
+ from pathlib import Path, PurePosixPath
17
+
18
+ from playwright.sync_api import sync_playwright
19
+
20
+ from bb_sync.blackboard import downloader, scraper
21
+ from bb_sync.blackboard.login import ensure_login
22
+ from bb_sync.blackboard.models import Course, DueItem, DueStats, FileItem, SyncStats
23
+ from bb_sync.browser import steel
24
+ from bb_sync.core import due
25
+ from bb_sync.core.config import Settings
26
+ from bb_sync.core.errors import ConfigError, NetworkError, NotFoundError
27
+ from bb_sync.core.output import Console
28
+
29
+
30
+ @dataclass
31
+ class SyncOptions:
32
+ """一次同步的运行时选项(已完成配置分层合并)。"""
33
+
34
+ settings: Settings
35
+ config_path: Path
36
+ root: Path
37
+ console: Console
38
+ headed: bool = False
39
+ dry_run: bool = False
40
+ courses: list[str] | None = None
41
+
42
+
43
+ @dataclass
44
+ class DueOptions:
45
+ """``bb-sync due`` 的运行时选项。"""
46
+
47
+ settings: Settings
48
+ root: Path
49
+ console: Console
50
+ headed: bool = False
51
+ courses: list[str] | None = None
52
+
53
+
54
+ @dataclass
55
+ class DueScan:
56
+ """一次全课程待办扫描的结果与失败统计。"""
57
+
58
+ items: list[DueItem] = field(default_factory=list)
59
+ todo_failed: int = 0 # Due / To Do 模块抓取失败的课程数
60
+ announcement_failed: int = 0 # 公告抓取失败的课程数
61
+ both_failed: int = 0 # 两个来源都失败的课程数(该课程结果完全缺失)
62
+
63
+ @property
64
+ def incomplete(self) -> int:
65
+ """至少一个来源失败的课程数,用于提示 due.md 可能不完整。"""
66
+ return self.todo_failed + self.announcement_failed - self.both_failed
67
+
68
+
69
+ def plan_root(settings: Settings, config_path: Path, cli_root: str | None) -> Path:
70
+ """计算下载根目录:CLI ``--root`` > 配置 ``root``。
71
+
72
+ 相对路径基准:CLI 传的相对路径以 CWD 为基准,配置里的相对路径以配置文件目录为基准。
73
+ """
74
+ if cli_root:
75
+ raw = Path(cli_root).expanduser()
76
+ return raw if raw.is_absolute() else (Path.cwd() / raw).resolve()
77
+ return settings.resolve_root(config_path)
78
+
79
+
80
+ def _macos_volume_root(root: Path) -> Path | None:
81
+ """返回根目录所属的 macOS ``/Volumes/<名称>``,不在该位置则返回 None。"""
82
+ if sys.platform != "darwin":
83
+ return None
84
+ posix = PurePosixPath(str(root).replace("\\", "/"))
85
+ if len(posix.parts) < 3 or posix.parts[:2] != ("/", "Volumes"):
86
+ return None
87
+ return Path(posix.parts[0]) / posix.parts[1] / posix.parts[2]
88
+
89
+
90
+ def _drive_available(root: Path) -> bool:
91
+ """检查盘符或 macOS 外接卷的根位置当前是否存在。"""
92
+ location = Path(root.anchor) if root.drive else _macos_volume_root(root)
93
+ if location is None:
94
+ return True
95
+ try:
96
+ return location.exists()
97
+ except OSError:
98
+ return False
99
+
100
+
101
+ def _drive_label(root: Path) -> str:
102
+ """磁盘不可用时,用于错误信息的盘符或挂载点。"""
103
+ if root.drive:
104
+ return root.drive
105
+ volume = _macos_volume_root(root)
106
+ return str(volume) if volume is not None else str(root)
107
+
108
+
109
+ def ensure_root_directory(root: Path) -> bool:
110
+ """确保下载根目录可用,返回调用前该目录是否尚不存在。"""
111
+ first_run = not root.exists()
112
+ if not _drive_available(root):
113
+ raise ConfigError(
114
+ f"下载根目录所在磁盘不可用:{_drive_label(root)}(当前配置:{root})",
115
+ "请确认该磁盘已连接/挂载,或用 --root <可用目录> 临时指定;"
116
+ "也可运行 bb-sync config set root <可用目录> 永久修改",
117
+ )
118
+ try:
119
+ root.mkdir(parents=True, exist_ok=True)
120
+ except OSError as exc:
121
+ raise ConfigError(
122
+ f"无法创建下载根目录:{root}",
123
+ f"{exc};请检查路径权限,或运行 bb-sync config set root <可用目录> 修改配置",
124
+ ) from exc
125
+ return first_run
126
+
127
+
128
+ def folder_name(course: Course, explicit_dirs: dict[str, str]) -> str:
129
+ """课程本地文件夹名:显式映射优先,否则自动生成。"""
130
+ if course.code and course.code in explicit_dirs:
131
+ return explicit_dirs[course.code]
132
+ return scraper.make_slug(course)
133
+
134
+
135
+ def sync_course(
136
+ page,
137
+ course: Course,
138
+ course_dir: Path,
139
+ options: SyncOptions,
140
+ stats: SyncStats,
141
+ downloads: downloader.DownloadSession,
142
+ ) -> None:
143
+ """同步单门课程:公告 → 内容区 → 逐文件下载。"""
144
+ console = options.console
145
+ settings = options.settings
146
+ keywords = settings.keywords
147
+
148
+ console.step(f"=== {course.code or '?'} | {course.title} → {course_dir} ===")
149
+ if not options.dry_run:
150
+ course_dir.mkdir(parents=True, exist_ok=True)
151
+
152
+ if settings.announcements:
153
+ scraper.scrape_announcements(page, course, course_dir, options.dry_run, console)
154
+
155
+ all_items: list[FileItem] = []
156
+ visited: set[str] = set()
157
+ menu = scraper.collect_menu_links(page, course)
158
+ console.log(" 内容区: " + (", ".join(n for _, n in menu) or "(无)"))
159
+ for area_url, area_name in menu:
160
+ all_items += scraper.find_files(
161
+ page, area_url, area_name, 1, settings.max_depth, keywords, visited
162
+ )
163
+
164
+ # 去重(同一文件可能出现在多个位置)
165
+ uniq: dict[str, FileItem] = {}
166
+ for item in all_items:
167
+ uniq.setdefault(item.url, item)
168
+ console.log(f" 发现 {len(uniq)} 个文件")
169
+
170
+ index = downloader.build_index(course_dir)
171
+ for item in uniq.values():
172
+ try:
173
+ status, name = downloads.download_item(item, course_dir, options.dry_run, index)
174
+ except Exception as exc: # 单个文件失败不中断整门课
175
+ stats.bump("failed")
176
+ console.warn(f" [{item.category}] 下载失败: {exc}")
177
+ continue
178
+ stats.bump(status)
179
+ _log_item(console, status, item.category, name)
180
+
181
+
182
+ def _log_item(console: Console, status: str, category: str, name: str) -> None:
183
+ """按状态打印单条下载结果(全部走 stderr)。"""
184
+ if status == "downloaded":
185
+ console.log(f" ↓ [{category}] {name}")
186
+ elif status == "would-download":
187
+ console.log(f" ? [{category}] {name} (dry-run)")
188
+ elif status == "exists":
189
+ console.log(f" = [{category}] {name} (已存在,跳过)")
190
+ elif status == "empty":
191
+ console.warn(f" [{category}] {name} 内容为空,跳过")
192
+
193
+
194
+ def _print_plan(console: Console, courses: list[Course], root: Path, dry_run: bool) -> None:
195
+ """开始下载前,先打印「将要写入哪些目录」的计划,方便确认落点。
196
+
197
+ 每个课程目录都显示为**绝对路径**——相对路径容易让人误判落盘位置。
198
+ """
199
+ console.rule("同步计划")
200
+ console.log(f"根目录: {root}")
201
+ console.log("课程目录:")
202
+ for course in courses:
203
+ label = course.code or course.title
204
+ console.log(f" {label:<10} → {root / course.slug}")
205
+ console.log(f"合计 {len(courses)} 门课程" + ("(dry-run,不会写入)" if dry_run else ""))
206
+ console.log("")
207
+
208
+
209
+ def _scan_due_items(page, courses: list[Course], console: Console) -> DueScan:
210
+ """逐课程抓取 Due / To Do 与公告里的课堂测验 / 考试,并统计失败情况。"""
211
+ scan = DueScan()
212
+ for course in courses:
213
+ label = course.code or course.title
214
+ todo_items: list[DueItem] = []
215
+ announcement_items: list[DueItem] = []
216
+ todo_ok = False
217
+ announcement_ok = False
218
+ try:
219
+ todo_items = scraper.scrape_due_items(page, course, console)
220
+ todo_ok = True
221
+ except Exception as exc: # 单门课失败不阻断其它课程
222
+ scan.todo_failed += 1
223
+ console.warn(f"[due] {label} To Do 抓取失败: {exc}")
224
+ try:
225
+ entries = scraper.scrape_announcement_entries(page, course)
226
+ announcement_items = scraper.parse_announcement_due_items(entries, course)
227
+ announcement_ok = True
228
+ except Exception as exc: # 公告失败不影响已读到的 To Do
229
+ scan.announcement_failed += 1
230
+ console.warn(f"[due] {label} 公告抓取失败: {exc}")
231
+ if not todo_ok and not announcement_ok:
232
+ scan.both_failed += 1
233
+ scan.items.extend(todo_items)
234
+ scan.items.extend(announcement_items)
235
+ parts = []
236
+ if todo_ok:
237
+ parts.append(f"待办 x{len(todo_items)}")
238
+ if announcement_items:
239
+ parts.append(f"公告测验/考试 x{len(announcement_items)}")
240
+ console.log(f" {label}: {','.join(parts) if parts else '未读取到内容'}")
241
+ scan.items = due.sort_due_items(scan.items)
242
+ return scan
243
+
244
+
245
+ def run_sync(options: SyncOptions) -> SyncStats:
246
+ """执行一次完整同步。"""
247
+ console = options.console
248
+ settings = options.settings
249
+
250
+ first_run = ensure_root_directory(options.root)
251
+ if first_run:
252
+ console.log(f"[info] 首次运行:课程文件将下载到 {options.root}")
253
+ console.log(" (永久修改:bb-sync config set root <目录>;临时指定:--root)")
254
+
255
+ include = options.courses if options.courses else settings.include
256
+ explicit_dirs = settings.course_dirs
257
+ stats = SyncStats()
258
+
259
+ # ---- Steel 后端:独立进程、静默无窗口、登录态持久化在 profile ----
260
+ steel.ensure_server(console, options.headed)
261
+ session = steel.create_session(options.headed, console)
262
+
263
+ try:
264
+ with sync_playwright() as p:
265
+ browser, ctx, page = steel.connect(p, session)
266
+ console.debug(f"[steel] CDP 已接入 (UA: {ctx.browser.version if ctx.browser else '?'})")
267
+
268
+ ensure_login(page, console, headed=options.headed)
269
+
270
+ courses = scraper.scrape_courses(page, include, console)
271
+ if not courses:
272
+ raise NotFoundError(
273
+ "未发现课程",
274
+ "确认已登录且账号下有课程;也可用 bb-sync course list 先看看",
275
+ )
276
+
277
+ # 先算出每门课的落盘目录并整体打印,再逐门开始下载
278
+ for course in courses:
279
+ course.slug = folder_name(course, explicit_dirs)
280
+ _print_plan(console, courses, options.root, options.dry_run)
281
+
282
+ downloads = downloader.DownloadSession(page, ctx.request)
283
+ for course in courses:
284
+ stats.courses.append(course.code or course.title)
285
+ sync_course(page, course, options.root / course.slug, options, stats, downloads)
286
+
287
+ scan = _scan_due_items(page, courses, console)
288
+ due_items = scan.items
289
+ due_path = options.root / "due.md"
290
+ if options.dry_run:
291
+ if scan.both_failed == len(courses):
292
+ message = "待办抓取全部失败"
293
+ elif not due_items and not scan.incomplete:
294
+ message = "Congratulations! 没有待办"
295
+ else:
296
+ message = f"待办 {len(due_items)} 项"
297
+ console.log(f"[due] {message}(dry-run,未写入 due.md)")
298
+ elif scan.both_failed == len(courses):
299
+ console.warn("[due] 所有课程待办抓取失败,保留现有 due.md")
300
+ else:
301
+ due.write_due_markdown(due_path, courses, due_items, failed=scan.incomplete)
302
+ if due_items:
303
+ console.log(f"[due] {len(due_items)} 项 → due.md")
304
+ elif scan.incomplete:
305
+ console.log("[due] 未读取到待办(部分课程失败)→ due.md")
306
+ else:
307
+ console.log("[due] Congratulations! 没有待办 → due.md")
308
+
309
+ ctx.close()
310
+ browser.close()
311
+ finally:
312
+ steel.release_session(session, console)
313
+
314
+ _print_summary(console, stats, options.dry_run)
315
+ if scan.both_failed < len(courses):
316
+ _print_due_details(console, due_items, scan.incomplete)
317
+ return stats
318
+
319
+
320
+ def _print_summary(console: Console, stats: SyncStats, dry_run: bool) -> None:
321
+ """汇总输出(stderr,人读)。"""
322
+ console.rule("汇总")
323
+ if dry_run:
324
+ console.log(f"待下载: {stats.would_download} 个文件(未实际下载)")
325
+ else:
326
+ console.log(f"新下载 {stats.downloaded} 个,已存在跳过 {stats.exists} 个")
327
+ if stats.failed:
328
+ console.warn(f"失败 {stats.failed} 个")
329
+
330
+
331
+ def _print_due_details(
332
+ console: Console, items: list[DueItem], failed: int = 0, *, today: date | None = None
333
+ ) -> None:
334
+ """在 ``run`` 最后逐行打印待办明细(stderr)。
335
+
336
+ 行格式 ``[due] 日期(状态)| 课程 | 标题``;只有**已经过去**的条目才带状态,
337
+ 否则整屏都是「还有 N 天」,反而看不出哪条已经过期。
338
+ """
339
+ if not items:
340
+ if failed:
341
+ console.log("[due] 没有可显示的待办明细(部分课程抓取失败)")
342
+ else:
343
+ console.log("[due] Congratulations! 没有待办。")
344
+ return
345
+ day = today or date.today()
346
+ console.rule("待办明细")
347
+ for item in items:
348
+ due_date = item.due_date.isoformat() if item.due_date else "日期未知"
349
+ if item.due_date is not None and item.due_date < day:
350
+ due_date = f"{due_date}({due.relative_due(item, day)})"
351
+ label = due.kind_label(item.kind)
352
+ badge = f"【{label}】" if label else ""
353
+ console.log(f"[due] {due_date} | {item.course_label} | {badge}{item.title}")
354
+
355
+
356
+ def list_courses(options: SyncOptions) -> list[Course]:
357
+ """仅列出课程(不下载),供 ``bb-sync course list`` 使用。"""
358
+ console = options.console
359
+ steel.ensure_server(console, options.headed)
360
+ session = steel.create_session(options.headed, console)
361
+ try:
362
+ with sync_playwright() as p:
363
+ browser, ctx, page = steel.connect(p, session)
364
+ ensure_login(page, console, headed=options.headed)
365
+ include = options.courses if options.courses else "all"
366
+ courses = scraper.scrape_courses(page, include, console)
367
+ for course in courses:
368
+ course.slug = folder_name(course, options.settings.course_dirs)
369
+ ctx.close()
370
+ browser.close()
371
+ finally:
372
+ steel.release_session(session, console)
373
+ return courses
374
+
375
+
376
+ def run_due(options: DueOptions) -> DueStats:
377
+ """扫描课程主页的 To Do 模块,并生成全局 ``due.md``。"""
378
+ console = options.console
379
+ settings = options.settings
380
+ first_run = ensure_root_directory(options.root)
381
+ if first_run:
382
+ console.log(f"[info] 待办文件将写入 {options.root / 'due.md'}")
383
+
384
+ include = options.courses if options.courses else settings.include
385
+ stats = DueStats(path=str(options.root / "due.md"))
386
+
387
+ steel.ensure_server(console, options.headed)
388
+ session = steel.create_session(options.headed, console)
389
+ try:
390
+ with sync_playwright() as p:
391
+ browser, ctx, page = steel.connect(p, session)
392
+ ensure_login(page, console, headed=options.headed)
393
+
394
+ courses = scraper.scrape_courses(page, include, console)
395
+ if not courses:
396
+ raise NotFoundError(
397
+ "未发现课程",
398
+ "确认已登录且账号下有课程;也可用 bb-sync course list 先看看",
399
+ )
400
+
401
+ stats.courses = [course.code or course.title for course in courses]
402
+ scan = _scan_due_items(page, courses, console)
403
+ stats.failed = scan.todo_failed
404
+ stats.announcement_failed = scan.announcement_failed
405
+
406
+ if scan.both_failed == len(courses):
407
+ raise NetworkError(
408
+ "所有课程的待办与公告都抓取失败",
409
+ "检查网络连接与登录状态,或先用 bb-sync run --headed 刷新登录态",
410
+ )
411
+
412
+ stats.items = scan.items
413
+ due.write_due_markdown(
414
+ options.root / "due.md",
415
+ courses,
416
+ scan.items,
417
+ failed=scan.incomplete,
418
+ )
419
+ ctx.close()
420
+ browser.close()
421
+ finally:
422
+ steel.release_session(session, console)
423
+
424
+ console.rule("待办汇总")
425
+ console.log(f"发现 {stats.total} 项待办,已写入 {stats.path}")
426
+ problems = []
427
+ if stats.failed:
428
+ problems.append(f"{stats.failed} 门课程 To Do 抓取失败")
429
+ if stats.announcement_failed:
430
+ problems.append(f"{stats.announcement_failed} 门课程公告抓取失败")
431
+ if problems:
432
+ console.warn(";".join(problems) + ",due.md 可能不完整")
433
+ return stats
434
+
435
+
436
+ __all__ = [
437
+ "SyncOptions",
438
+ "DueOptions",
439
+ "ensure_root_directory",
440
+ "folder_name",
441
+ "list_courses",
442
+ "plan_root",
443
+ "run_sync",
444
+ "run_due",
445
+ "sync_course",
446
+ ]