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,695 @@
1
+ """内容区抓取:课程列表、左菜单内容区、附件链接、公告。
2
+
3
+ Blackboard 经典版的几个关键事实(实测结论,勿轻易改动):
4
+
5
+ - 课程列表从门户首页的 My Courses 模块取,DOM 查询 ``a[href*='launcher?type=Course']``
6
+ 比正则稳(链接里有 ``&`` 编码坑)
7
+ - 课程首页 launcher 会跳到 ``modulepage/view``,左菜单里 ``listContent.jsp`` 才是
8
+ 真实内容区;``launchLink.jsp`` 是讨论区/工具,不抓
9
+ - 附件一律是 ``bbcswebdav`` 链接
10
+ - 公告 URL 必须带 ``method=search&context=mybb&viewChoice=2``
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import re
16
+ import time
17
+ from contextlib import suppress
18
+ from datetime import date, datetime, timedelta
19
+ from pathlib import Path
20
+
21
+ from playwright.sync_api import Page
22
+ from playwright.sync_api import TimeoutError as PWTimeout
23
+
24
+ from bb_sync.blackboard.models import (
25
+ _PORTAL_COURSES,
26
+ _PORTAL_HOME,
27
+ BASE,
28
+ Course,
29
+ DueItem,
30
+ FileItem,
31
+ )
32
+ from bb_sync.core.output import Console
33
+
34
+ # ---------------------------------------------------------------- 文本工具
35
+
36
+
37
+ def match_category(text: str, keywords: dict) -> str | None:
38
+ """命中关键词返回分类,未命中返回 None(由调用方决定默认值)。"""
39
+ t = (text or "").lower()
40
+ for cat in ("assignments", "tutorials"): # 优先级高的先匹配
41
+ for kw in keywords.get(cat, []):
42
+ if kw.lower() in t:
43
+ return cat
44
+ for kw in keywords.get("lectures", []):
45
+ if kw.lower() in t:
46
+ return "lectures"
47
+ return None
48
+
49
+
50
+ def categorize(text: str, keywords: dict) -> str:
51
+ """分类兜底:未命中任何关键词归 lectures。"""
52
+ return match_category(text, keywords) or "lectures"
53
+
54
+
55
+ _STOP_WORDS = {
56
+ "THE",
57
+ "AND",
58
+ "FOR",
59
+ "OF",
60
+ "IN",
61
+ "WITH",
62
+ "FROM",
63
+ "INTO",
64
+ "USING",
65
+ "TO",
66
+ "THEIR",
67
+ "SEMESTER",
68
+ "FALL",
69
+ "SPRING",
70
+ "SUMMER",
71
+ "SECTION",
72
+ "TERM",
73
+ "PART",
74
+ }
75
+
76
+
77
+ def make_slug(course: Course) -> str:
78
+ """课程代码 + 标题里的英文关键词,如 ``CSC5010_Artificial_Intelligence``。
79
+
80
+ 最多取 3 个非停用词、总长 ≤40,避免把 "Deep Learning Applications" 截成 "Deep"。
81
+ """
82
+ if not course.code:
83
+ safe = re.sub(r"[^\w\u4e00-\u9fff]+", "_", course.title).strip("_")
84
+ return safe[:40]
85
+ rest = course.title.upper().replace(course.code.upper(), "", 1)
86
+ words = re.findall(r"[A-Za-z][A-Za-z-]{2,}", rest)
87
+ keys: list[str] = []
88
+ total = 0
89
+ for w in words:
90
+ if w.upper() in _STOP_WORDS:
91
+ continue
92
+ if total + len(w) + 1 > 40:
93
+ break
94
+ keys.append(w.strip("-").capitalize())
95
+ total += len(w) + 1
96
+ if len(keys) >= 3:
97
+ break
98
+ key = "_".join(keys)
99
+ return f"{course.code}_{key}" if key else course.code
100
+
101
+
102
+ # ---------------------------------------------------------------- 页面抓取
103
+
104
+
105
+ def _wait_for_content(page: Page, selector: str, timeout_ms: int, *, settle_ms: int = 300) -> None:
106
+ """等待关键内容渲染,最多不超过原来的固定等待时间。
107
+
108
+ 原实现用固定 ``wait_for_timeout`` 盲等:快页面浪费时间,慢页面又可能不够。
109
+ 改为等目标选择器出现,再等网络空闲以排除「只渲染了一半」的窗口;选择器未出现(空列表等)
110
+ 时等满 ``timeout_ms`` 后继续,与原来的固定等待等价,不会更慢。
111
+ """
112
+ deadline = time.monotonic() + timeout_ms / 1000
113
+ try:
114
+ page.wait_for_selector(selector, timeout=timeout_ms, state="attached")
115
+ except PWTimeout:
116
+ return
117
+ remaining_ms = max(0, int((deadline - time.monotonic()) * 1000))
118
+ if remaining_ms:
119
+ with suppress(PWTimeout):
120
+ page.wait_for_load_state("networkidle", timeout=remaining_ms)
121
+ remaining_ms = max(0, int((deadline - time.monotonic()) * 1000))
122
+ if remaining_ms:
123
+ page.wait_for_timeout(min(settle_ms, remaining_ms))
124
+
125
+
126
+ def _course_links(page: Page, console: Console) -> list[tuple[str, str]]:
127
+ """从「My Courses」取课程链接:优先 My Courses 标签页,再退回首标签页。"""
128
+ for url in (_PORTAL_COURSES, _PORTAL_HOME):
129
+ try:
130
+ page.goto(url, wait_until="domcontentloaded", timeout=30000)
131
+ _wait_for_content(page, "a[href*='launcher?type=Course']", 3500)
132
+ pairs = page.eval_on_selector_all(
133
+ "a[href*='launcher?type=Course']",
134
+ "els => els.map(e => [e.href, (e.innerText || '').trim()])",
135
+ )
136
+ if pairs:
137
+ console.debug(f"[scan] 课程来源: {url}")
138
+ return pairs
139
+ except PWTimeout:
140
+ continue
141
+ return []
142
+
143
+
144
+ def scrape_courses(page: Page, include, console: Console) -> list[Course]:
145
+ """抓取课程列表并按 ``include`` 过滤。"""
146
+ console.step("[scan] 获取课程列表 ...")
147
+ pairs = _course_links(page, console)
148
+
149
+ courses: list[Course] = []
150
+ seen: set[str] = set()
151
+ for href, title in pairs:
152
+ m = re.search(r"id=(_\d+_1)", href)
153
+ if not m:
154
+ continue
155
+ bb_id = m.group(1)
156
+ if bb_id in seen:
157
+ continue
158
+ seen.add(bb_id)
159
+ courses.append(Course(bb_id=bb_id, title=title or bb_id))
160
+
161
+ if include and include != "all":
162
+ wanted = {c.upper() for c in (include if isinstance(include, list) else [include])}
163
+ courses = [c for c in courses if c.code and c.code.upper() in wanted]
164
+
165
+ summary = ", ".join(c.code or c.title for c in courses) or "(无)"
166
+ console.log(f"[scan] 发现 {len(courses)} 门课程: {summary}")
167
+ return courses
168
+
169
+
170
+ def collect_menu_links(page: Page, course: Course) -> list[tuple[str, str]]:
171
+ """获取课程左侧菜单中的内容区链接,返回 ``[(url, 菜单名), ...]``。"""
172
+ page.goto(
173
+ f"{BASE}/webapps/blackboard/execute/launcher?type=Course&id={course.bb_id}",
174
+ wait_until="domcontentloaded",
175
+ timeout=30000,
176
+ )
177
+ _wait_for_content(page, "a[href*='listContent.jsp']", 2500)
178
+ pairs = page.eval_on_selector_all(
179
+ "a[href*='listContent.jsp']", "els => els.map(e => [e.href, (e.innerText || '').trim()])"
180
+ )
181
+ seen: set[str] = set()
182
+ out: list[tuple[str, str]] = []
183
+ for href, name in pairs:
184
+ m = re.search(r"content_id=(_\d+_1)", href)
185
+ if not m:
186
+ continue
187
+ cid = m.group(1)
188
+ if cid in seen:
189
+ continue
190
+ seen.add(cid)
191
+ out.append((href, name or cid))
192
+ return out
193
+
194
+
195
+ def sanitize_filename(name: str) -> str:
196
+ """把标题转成合法文件名(去非法字符、截断到 150 字符)。"""
197
+ import unicodedata
198
+
199
+ name = unicodedata.normalize("NFKC", name).strip()
200
+ name = re.sub(r'[\\/:*?"<>|]+', "_", name)
201
+ return name[:150] or "untitled"
202
+
203
+
204
+ _DUE_DATE_RE = re.compile(r"(\d{1,2}/\d{1,2}/\d{2,4})")
205
+
206
+
207
+ def parse_due_date(text: str) -> date | None:
208
+ """解析 Blackboard Due 文本里的 ``MM/DD/YY`` 或 ``MM/DD/YYYY`` 日期。"""
209
+ match = _DUE_DATE_RE.search(text or "")
210
+ if not match:
211
+ return None
212
+ raw = match.group(1)
213
+ for fmt in ("%m/%d/%y", "%m/%d/%Y"):
214
+ try:
215
+ return datetime.strptime(raw, fmt).date()
216
+ except ValueError:
217
+ continue
218
+ return None
219
+
220
+
221
+ def parse_due_entries(entries: list[dict[str, str]], course: Course) -> list[DueItem]:
222
+ """把浏览器提取的原始 Due 行整理成领域对象,并去掉跨区块重复项。"""
223
+ items: list[DueItem] = []
224
+ seen: set[tuple[str, str]] = set()
225
+ for entry in entries:
226
+ title = re.sub(r"\s+", " ", str(entry.get("title") or "")).strip()
227
+ due_text = re.sub(r"\s+", " ", str(entry.get("due_text") or "")).strip()
228
+ if not title:
229
+ continue
230
+ due_date = parse_due_date(due_text)
231
+ key = (title, due_date.isoformat() if due_date else due_text)
232
+ if key in seen:
233
+ continue
234
+ seen.add(key)
235
+ items.append(
236
+ DueItem(
237
+ course_id=course.bb_id,
238
+ course_code=course.code,
239
+ course_title=course.title,
240
+ title=title,
241
+ due_date=due_date,
242
+ due_text=due_text,
243
+ )
244
+ )
245
+ return items
246
+
247
+
248
+ def find_files(
249
+ page: Page,
250
+ url: str,
251
+ area_name: str,
252
+ depth: int,
253
+ max_depth: int,
254
+ keywords: dict,
255
+ visited: set[str],
256
+ ) -> list[FileItem]:
257
+ """递归抓取内容页中的附件(bbcswebdav)与子文件夹。"""
258
+ if depth > max_depth or url in visited:
259
+ return []
260
+ visited.add(url)
261
+ try:
262
+ resp = page.goto(url, wait_until="domcontentloaded", timeout=30000)
263
+ _wait_for_content(page, "a[href*='bbcswebdav']", 1000)
264
+ if resp and resp.status >= 400:
265
+ return []
266
+ except PWTimeout:
267
+ return []
268
+
269
+ items: list[FileItem] = []
270
+ # 附件:优先级「文件名关键词 > 内容区名关键词」
271
+ files = page.eval_on_selector_all(
272
+ "a[href*='bbcswebdav']", "els => els.map(e => [e.href, (e.innerText || '').trim()])"
273
+ )
274
+ for href, name in files:
275
+ cat = match_category(name, keywords) or match_category(area_name, keywords) or "lectures"
276
+ items.append(
277
+ FileItem(
278
+ name=sanitize_filename(name) if name else None,
279
+ url=href,
280
+ category=cat,
281
+ folder_hint=area_name,
282
+ )
283
+ )
284
+
285
+ # 子文件夹:内容区里的 listContent.jsp 条目
286
+ if depth < max_depth:
287
+ subs = page.eval_on_selector_all(
288
+ "a[href*='listContent.jsp']",
289
+ "els => els.map(e => [e.href, (e.innerText || '').trim()])",
290
+ )
291
+ seen_sub: set[str] = set()
292
+ for href, sub_name in subs:
293
+ m = re.search(r"content_id=(_\d+_1)", href)
294
+ if not m or m.group(1) in seen_sub:
295
+ continue
296
+ seen_sub.add(m.group(1))
297
+ items += find_files(
298
+ page, href, sub_name or area_name, depth + 1, max_depth, keywords, visited
299
+ )
300
+ return items
301
+
302
+
303
+ def scrape_due_items(page: Page, course: Course, console: Console) -> list[DueItem]:
304
+ """抓取课程主页 To Do 模块中的逾期与待办项目。"""
305
+ home_url = f"{BASE}/webapps/blackboard/execute/launcher?type=Course&id={course.bb_id}"
306
+ resp = page.goto(home_url, wait_until="domcontentloaded", timeout=30000)
307
+ _wait_for_content(page, "#pastDueView li, #dueView li", 1200)
308
+ if resp and resp.status >= 400:
309
+ raise RuntimeError(f"课程主页返回 HTTP {resp.status}")
310
+
311
+ entries = page.eval_on_selector_all(
312
+ "#pastDueView li, #dueView li",
313
+ """els => els
314
+ .filter(e => e.querySelector('.due') && !e.querySelector('li'))
315
+ .map(e => ({
316
+ title: (e.querySelector('a:not(.cmimg)')?.innerText || '').trim(),
317
+ due_text: (e.querySelector('.due')?.innerText || '').trim(),
318
+ }))""",
319
+ )
320
+ items = parse_due_entries(entries, course)
321
+ console.debug(f"[due] {course.code or course.title}: {len(items)} 项")
322
+ return items
323
+
324
+
325
+ # ---------------------------------------------------------------- 公告待办解析
326
+
327
+ #: 月份前三字母 → 月号;容忍 Octorber 这类拼写错误与 Sept 这类缩写。
328
+ _MONTH_BY_PREFIX = {
329
+ "jan": 1,
330
+ "feb": 2,
331
+ "mar": 3,
332
+ "apr": 4,
333
+ "may": 5,
334
+ "jun": 6,
335
+ "jul": 7,
336
+ "aug": 8,
337
+ "sep": 9,
338
+ "oct": 10,
339
+ "nov": 11,
340
+ "dec": 12,
341
+ }
342
+
343
+ #: 英文日期两种语序;分隔允许空格或点(Oct.13 / Oct 13 / 13 November 2026)。
344
+ _MONTH_DAY_RE = re.compile(
345
+ r"\b(?P<month>[A-Za-z]{3,12})\.?[ \t]*(?P<day>\d{1,2})(?:st|nd|rd|th)?(?!\d)"
346
+ r"(?:\s*,?\s*(?P<year>\d{4})(?!\d))?",
347
+ re.IGNORECASE,
348
+ )
349
+ _DAY_MONTH_RE = re.compile(
350
+ r"\b(?P<day>\d{1,2})(?:st|nd|rd|th)?(?!\d)\s+(?P<month>[A-Za-z]{3,12})\.?"
351
+ r"(?:\s*,?\s*(?P<year>\d{4})(?!\d))?",
352
+ re.IGNORECASE,
353
+ )
354
+ _CN_DATE_RE = re.compile(
355
+ r"(?:(?P<year>\d{4})\s*年\s*)?(?P<month>\d{1,2})\s*月\s*(?P<day>\d{1,2})\s*日"
356
+ )
357
+
358
+ #: 公告里的课堂测验关键词;\b 防止把 coding_quiz-1.zip 这类内部下划线文件名当测验。
359
+ _QUIZ_RE = re.compile(r"\bquiz\w*|小测|随堂测|测验", re.IGNORECASE)
360
+
361
+ #: 考试关键词:exam 系词(含 examination)与中文说法,外加 midterm / final 与
362
+ #: test 的组合(Final Test、Midterm Make-up Test)。单独出现的 midterm / final /
363
+ #: test 不算——「final report」「test your code」这类日常措辞太容易误报。
364
+ #: exam 后必须直接收尾,否则 \bexam\w* 会把「for example」也算成考试。
365
+ _EXAM_RE = re.compile(
366
+ r"\bexam(?:s|ination|inations)?\b|考试|考卷|(?:期中|期末)考"
367
+ r"|\b(?:mid[\s-]?term|final)\b[^.\n]{0,20}?\btest\w*",
368
+ re.IGNORECASE,
369
+ )
370
+
371
+ #: 待办类型 → 公告关键词;顺序即归类优先级:一条公告同时提到考试和测验时按考试算
372
+ #: (「Final Exam 覆盖 Quiz 1-3 内容」应归为考试)。
373
+ _ANNOUNCEMENT_KIND_PATTERNS: tuple[tuple[str, re.Pattern[str]], ...] = (
374
+ ("exam", _EXAM_RE),
375
+ ("quiz", _QUIZ_RE),
376
+ )
377
+
378
+ #: 关键词之后最多检查这么多字符,避免把远处的日期牵强算成考试/测验日期。
379
+ _KEYWORD_DATE_WINDOW = 240
380
+
381
+ #: 公告里的一次性事件(测验 / 考试)过了日期后仍然收录这么多天,更早的视为历史。
382
+ #: 公告页会列出整学期的公告,没有这条限制的话考完的测验会一直堆在 due.md 里。
383
+ _PAST_EVENT_RETENTION_DAYS = 7
384
+
385
+
386
+ def _month_number(word: str) -> int | None:
387
+ """按前三字母识别月份,返回月号;不是月份返回 None。"""
388
+ return _MONTH_BY_PREFIX.get(word[:3].lower())
389
+
390
+
391
+ def _make_date(year: int | None, month: int, day: int, reference: date) -> date | None:
392
+ """组装并校验日期,无效返回 None。
393
+
394
+ 缺年份时用 reference 的年份;若推断结果早于 reference,且处于「12 月公告提到
395
+ 次年 1/2 月」这类学期跨年场景,则顺延一年。显式写了年份的日期不做推断。
396
+ """
397
+ if year is None:
398
+ try:
399
+ value = date(reference.year, month, day)
400
+ except ValueError:
401
+ return None
402
+ if value < reference and reference.month >= 9 and value.month <= 2:
403
+ try:
404
+ value = date(reference.year + 1, month, day)
405
+ except ValueError:
406
+ return None
407
+ return value
408
+ try:
409
+ return date(year, month, day)
410
+ except ValueError:
411
+ return None
412
+
413
+
414
+ def _find_dates(text: str, reference: date) -> list[tuple[int, date]]:
415
+ """提取文本里的日期,返回按出现位置排序的 ``(位置, 日期)``;无效日期忽略。"""
416
+ found: dict[int, date] = {}
417
+
418
+ def add(pos: int, value: date | None) -> None:
419
+ if value is not None and pos not in found:
420
+ found[pos] = value
421
+
422
+ for match in _DUE_DATE_RE.finditer(text):
423
+ add(match.start(), parse_due_date(match.group(1)))
424
+ for match in _CN_DATE_RE.finditer(text):
425
+ raw_year = match.group("year")
426
+ add(
427
+ match.start(),
428
+ _make_date(
429
+ int(raw_year) if raw_year else None,
430
+ int(match.group("month")),
431
+ int(match.group("day")),
432
+ reference,
433
+ ),
434
+ )
435
+ for pattern in (_MONTH_DAY_RE, _DAY_MONTH_RE):
436
+ for match in pattern.finditer(text):
437
+ month = _month_number(match.group("month"))
438
+ if month is None:
439
+ continue
440
+ raw_year = match.group("year")
441
+ add(
442
+ match.start(),
443
+ _make_date(
444
+ int(raw_year) if raw_year else None,
445
+ month,
446
+ int(match.group("day")),
447
+ reference,
448
+ ),
449
+ )
450
+ return sorted(found.items())
451
+
452
+
453
+ def parse_announcement_date(text: str, *, reference: date | None = None) -> date | None:
454
+ """解析公告文本里的第一个日期;缺年份时用 reference(默认今天)补全。"""
455
+ if not text:
456
+ return None
457
+ dates = _find_dates(text, reference or date.today())
458
+ return dates[0][1] if dates else None
459
+
460
+
461
+ def _pick_future_date(candidates: list[tuple[int, date]], posted: date | None) -> date | None:
462
+ """取第一个不早于公告发布时间的日期;全部早于发布时间视为历史信息。"""
463
+ if posted is None:
464
+ return candidates[0][1] if candidates else None
465
+ for _, value in candidates:
466
+ if value >= posted:
467
+ return value
468
+ return None
469
+
470
+
471
+ def _announcement_kind(text: str) -> tuple[str, re.Pattern[str]] | None:
472
+ """判断公告属于哪类待办,返回 ``(kind, 关键词正则)``;都不命中返回 None。"""
473
+ for kind, pattern in _ANNOUNCEMENT_KIND_PATTERNS:
474
+ if pattern.search(text):
475
+ return kind, pattern
476
+ return None
477
+
478
+
479
+ def _extract_keyword_date(
480
+ title: str, body: str, posted: date | None, pattern: re.Pattern[str]
481
+ ) -> date | None:
482
+ """从公告里定位关键词附近的日期。
483
+
484
+ 只看标题与正文中关键词之后、同一段内的日期:日期必须写在测验 / 考试
485
+ 名称后面,避免把关键词之前的作业截止日期误当成考试日期。
486
+ """
487
+ reference = posted or date.today()
488
+ if pattern.search(title):
489
+ chosen = _pick_future_date(_find_dates(title, reference), posted)
490
+ if chosen:
491
+ return chosen
492
+ text = f"{title}\n{body}" if body else title
493
+ for match in pattern.finditer(text):
494
+ window = text[match.end() : match.end() + _KEYWORD_DATE_WINDOW]
495
+ para_end = window.find("\n\n")
496
+ if para_end >= 0:
497
+ window = window[:para_end]
498
+ chosen = _pick_future_date(_find_dates(window, reference), posted)
499
+ if chosen:
500
+ return chosen
501
+ return None
502
+
503
+
504
+ def _announcement_label_from_body(body: str, pattern: re.Pattern[str]) -> str:
505
+ """标题为空时,用正文里带关键词的那一行兜底做条目标题。"""
506
+ for line in body.splitlines():
507
+ if pattern.search(line):
508
+ return line.strip()[:120]
509
+ return ""
510
+
511
+
512
+ def parse_announcement_due_items(
513
+ entries: list[dict[str, str]], course: Course, *, today: date | None = None
514
+ ) -> list[DueItem]:
515
+ """把公告里带日期的课堂测验与考试整理成待办条目。
516
+
517
+ 读不出日期、日期早于公告发布时间、或日期已过去超过 ``_PAST_EVENT_RETENTION_DAYS``
518
+ 天的公告一律跳过:宁可不收录,也不要把「复习资料里提到 quiz」误当成待办;
519
+ 已经考完的测验 / 考试只值得回看,不该继续占着待办位。
520
+ """
521
+ oldest = (today or date.today()) - timedelta(days=_PAST_EVENT_RETENTION_DAYS)
522
+ items: list[DueItem] = []
523
+ seen: set[tuple[str, str]] = set()
524
+ for entry in entries:
525
+ title = _clean_announcement_text(str(entry.get("title") or "")).replace("\n", " ").strip()
526
+ body = _clean_announcement_text(str(entry.get("body_markdown") or entry.get("body") or ""))
527
+ if not title and not body:
528
+ continue
529
+ matched = _announcement_kind(f"{title}\n{body}" if body else title)
530
+ if matched is None:
531
+ continue
532
+ kind, pattern = matched
533
+ posted = parse_announcement_date(str(entry.get("posted_on") or ""))
534
+ due_date = _extract_keyword_date(title, body, posted, pattern)
535
+ if due_date is None or due_date < oldest:
536
+ continue
537
+ label = title or _announcement_label_from_body(body, pattern) or course.code or course.title
538
+ key = (due_date.isoformat(), label.casefold())
539
+ if key in seen:
540
+ continue
541
+ seen.add(key)
542
+ items.append(
543
+ DueItem(
544
+ course_id=course.bb_id,
545
+ course_code=course.code,
546
+ course_title=course.title,
547
+ title=label,
548
+ due_date=due_date,
549
+ kind=kind,
550
+ )
551
+ )
552
+ return items
553
+
554
+
555
+ def _clean_announcement_text(value: str) -> str:
556
+ """归一文本:压缩行尾空白,但保留段落和列表产生的换行。"""
557
+ text = value.replace("\xa0", " ").replace("\r\n", "\n").replace("\r", "\n")
558
+ lines = [re.sub(r"[ \t]+", " ", line).strip() for line in text.split("\n")]
559
+ while lines and not lines[0]:
560
+ lines.pop(0)
561
+ while lines and not lines[-1]:
562
+ lines.pop()
563
+ return re.sub(r"\n{3,}", "\n\n", "\n".join(lines))
564
+
565
+
566
+ def build_announcements_markdown(course_title: str, entries: list[dict[str, str]]) -> str:
567
+ """把结构化公告条目渲染为 ``announcements.md`` 内容。"""
568
+ lines = [f"# {course_title} — 公告", ""]
569
+ for entry in entries:
570
+ title = _clean_announcement_text(entry.get("title", "")).replace("\n", " ") or "(无标题)"
571
+ posted_on = _clean_announcement_text(entry.get("posted_on", "")).replace("\n", " ")
572
+ body = _clean_announcement_text(entry.get("body_markdown") or entry.get("body", ""))
573
+ posted_by = _clean_announcement_text(entry.get("posted_by", ""))
574
+
575
+ lines.extend([f"## {title}", ""])
576
+ if posted_on:
577
+ lines.extend([f"> {posted_on}", ""])
578
+ if body:
579
+ lines.extend([body, ""])
580
+ if posted_by:
581
+ lines.extend([f"> {posted_by.replace(chr(10), chr(10) + '> ')}", ""])
582
+ return "\n".join(lines).rstrip() + "\n"
583
+
584
+
585
+ def scrape_announcement_entries(page: Page, course: Course) -> list[dict[str, str]]:
586
+ """抓取课程公告页的结构化条目(不落盘;失败抛异常,由调用方决定处理方式)。"""
587
+ page.goto(
588
+ f"{BASE}/webapps/blackboard/execute/announcement?method=search"
589
+ f"&context=mybb&viewChoice=2&course_id={course.bb_id}",
590
+ wait_until="domcontentloaded",
591
+ timeout=20000,
592
+ )
593
+ _wait_for_content(page, "#announcementList > li", 1000)
594
+ # 经典版公告页的 li 本身没有 announcement class;唯一可靠的容器是列表本身。
595
+ entries: list[dict[str, str]] = page.eval_on_selector_all(
596
+ "#announcementList > li",
597
+ """els => {
598
+ const toMarkdown = root => {
599
+ const render = node => {
600
+ if (!node) return '';
601
+ if (node.nodeType === Node.TEXT_NODE) return node.nodeValue || '';
602
+ if (node.nodeType !== Node.ELEMENT_NODE) return '';
603
+ const tag = node.tagName.toLowerCase();
604
+ const content = Array.from(node.childNodes).map(render).join('');
605
+ if (tag === 'br') return '\\n';
606
+ if (tag === 'p') return content.trim() + '\\n\\n';
607
+ if (tag === 'a') {
608
+ const label = content.trim();
609
+ return node.href
610
+ ? '[' + (label || node.href) + '](' + node.href + ')'
611
+ : label;
612
+ }
613
+ if (tag === 'img') {
614
+ const alt = (node.getAttribute('alt') || '公告图片').trim();
615
+ return node.src ? '![' + alt + '](' + node.src + ')' : '';
616
+ }
617
+ if (tag === 'strong' || tag === 'b') return '**' + content.trim() + '**';
618
+ if (tag === 'em' || tag === 'i') return '*' + content.trim() + '*';
619
+ if (tag === 'code') return '`' + content.trim() + '`';
620
+ if (tag === 'ul' || tag === 'ol') {
621
+ const ordered = tag === 'ol';
622
+ const start = Number.parseInt(node.getAttribute('start') || '1', 10) || 1;
623
+ let index = 0;
624
+ return Array.from(node.children)
625
+ .filter(child => child.tagName && child.tagName.toLowerCase() === 'li')
626
+ .map(li => {
627
+ const prefix = ordered ? (start + index++) + '. ' : '- ';
628
+ const item = render(li).trim().replace(
629
+ /\\n/g, '\\n' + ' '.repeat(prefix.length)
630
+ );
631
+ return prefix + item + '\\n';
632
+ }).join('') + '\\n';
633
+ }
634
+ if (tag === 'li') return content.trim() + '\\n';
635
+ if (tag === 'hr') return '\\n---\\n\\n';
636
+ return content;
637
+ };
638
+ return render(root)
639
+ .replace(/[ \\t]+\\n/g, '\\n')
640
+ .replace(/\\n{3,}/g, '\\n\\n')
641
+ .trim();
642
+ };
643
+ return els.map(li => {
644
+ const details = li.querySelector('.details');
645
+ const body = details?.querySelector('.vtbegenerated');
646
+ const info = li.querySelector('.announcementInfo');
647
+ return {
648
+ title: (li.querySelector('h3.item, h3')?.innerText || '').trim(),
649
+ posted_on: (details?.querySelector('p')?.innerText || '').trim(),
650
+ body_markdown: body ? toMarkdown(body) : '',
651
+ posted_by: Array.from(info?.querySelectorAll('p') || [])
652
+ .map(p => (p.innerText || '').trim()).filter(Boolean).join('\\n'),
653
+ };
654
+ });
655
+ }""",
656
+ )
657
+ return entries
658
+
659
+
660
+ def scrape_announcements(
661
+ page: Page, course: Course, course_dir: Path, dry_run: bool, console: Console
662
+ ) -> int:
663
+ """抓取公告写入 ``announcements.md``;返回写入条数(失败不阻塞主流程)。"""
664
+ try:
665
+ entries = scrape_announcement_entries(page, course)
666
+ count = len(entries)
667
+ if count and not dry_run:
668
+ course_dir.mkdir(parents=True, exist_ok=True)
669
+ (course_dir / "announcements.md").write_text(
670
+ build_announcements_markdown(course.title, entries), encoding="utf-8"
671
+ )
672
+ console.log(f" 公告 x{count} → announcements.md")
673
+ return count
674
+ except Exception as exc: # 公告失败不阻塞主流程
675
+ console.warn(f"公告抓取失败: {exc}")
676
+ return 0
677
+
678
+
679
+ __all__ = [
680
+ "categorize",
681
+ "build_announcements_markdown",
682
+ "collect_menu_links",
683
+ "find_files",
684
+ "make_slug",
685
+ "match_category",
686
+ "parse_announcement_date",
687
+ "parse_announcement_due_items",
688
+ "parse_due_date",
689
+ "parse_due_entries",
690
+ "sanitize_filename",
691
+ "scrape_announcement_entries",
692
+ "scrape_announcements",
693
+ "scrape_courses",
694
+ "scrape_due_items",
695
+ ]