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
|
@@ -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 ? '' : '';
|
|
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
|
+
]
|