bb-sync 2.1.0__tar.gz → 2.2.0__tar.gz
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-2.1.0 → bb_sync-2.2.0}/CHANGELOG.md +31 -0
- {bb_sync-2.1.0 → bb_sync-2.2.0}/PKG-INFO +3 -3
- {bb_sync-2.1.0 → bb_sync-2.2.0}/README.md +2 -2
- {bb_sync-2.1.0 → bb_sync-2.2.0}/docs/scripting.md +5 -3
- {bb_sync-2.1.0 → bb_sync-2.2.0}/pyproject.toml +1 -1
- {bb_sync-2.1.0 → bb_sync-2.2.0}/src/bb_sync/blackboard/scraper.py +100 -30
- bb_sync-2.2.0/src/bb_sync/core/due.py +335 -0
- {bb_sync-2.1.0 → bb_sync-2.2.0}/src/bb_sync/core/service.py +16 -22
- {bb_sync-2.1.0 → bb_sync-2.2.0}/tests/test_units.py +279 -5
- {bb_sync-2.1.0 → bb_sync-2.2.0}/uv.lock +1 -1
- bb_sync-2.1.0/src/bb_sync/core/due.py +0 -192
- {bb_sync-2.1.0 → bb_sync-2.2.0}/.github/workflows/ci.yml +0 -0
- {bb_sync-2.1.0 → bb_sync-2.2.0}/.github/workflows/release.yml +0 -0
- {bb_sync-2.1.0 → bb_sync-2.2.0}/.gitignore +0 -0
- {bb_sync-2.1.0 → bb_sync-2.2.0}/.pre-commit-config.yaml +0 -0
- {bb_sync-2.1.0 → bb_sync-2.2.0}/AGENTS.md +0 -0
- {bb_sync-2.1.0 → bb_sync-2.2.0}/CONTRIBUTING.md +0 -0
- {bb_sync-2.1.0 → bb_sync-2.2.0}/LICENSE +0 -0
- {bb_sync-2.1.0 → bb_sync-2.2.0}/config.yaml +0 -0
- {bb_sync-2.1.0 → bb_sync-2.2.0}/docs/config.md +0 -0
- {bb_sync-2.1.0 → bb_sync-2.2.0}/scripts/debug_course.py +0 -0
- {bb_sync-2.1.0 → bb_sync-2.2.0}/scripts/debug_courses.py +0 -0
- {bb_sync-2.1.0 → bb_sync-2.2.0}/scripts/debug_login.py +0 -0
- {bb_sync-2.1.0 → bb_sync-2.2.0}/src/bb_sync/__init__.py +0 -0
- {bb_sync-2.1.0 → bb_sync-2.2.0}/src/bb_sync/__main__.py +0 -0
- {bb_sync-2.1.0 → bb_sync-2.2.0}/src/bb_sync/blackboard/__init__.py +0 -0
- {bb_sync-2.1.0 → bb_sync-2.2.0}/src/bb_sync/blackboard/downloader.py +0 -0
- {bb_sync-2.1.0 → bb_sync-2.2.0}/src/bb_sync/blackboard/login.py +0 -0
- {bb_sync-2.1.0 → bb_sync-2.2.0}/src/bb_sync/blackboard/models.py +0 -0
- {bb_sync-2.1.0 → bb_sync-2.2.0}/src/bb_sync/browser/__init__.py +0 -0
- {bb_sync-2.1.0 → bb_sync-2.2.0}/src/bb_sync/browser/launcher.py +0 -0
- {bb_sync-2.1.0 → bb_sync-2.2.0}/src/bb_sync/cli.py +0 -0
- {bb_sync-2.1.0 → bb_sync-2.2.0}/src/bb_sync/commands/__init__.py +0 -0
- {bb_sync-2.1.0 → bb_sync-2.2.0}/src/bb_sync/commands/auth.py +0 -0
- {bb_sync-2.1.0 → bb_sync-2.2.0}/src/bb_sync/commands/browser.py +0 -0
- {bb_sync-2.1.0 → bb_sync-2.2.0}/src/bb_sync/commands/config.py +0 -0
- {bb_sync-2.1.0 → bb_sync-2.2.0}/src/bb_sync/commands/course.py +0 -0
- {bb_sync-2.1.0 → bb_sync-2.2.0}/src/bb_sync/commands/doctor.py +0 -0
- {bb_sync-2.1.0 → bb_sync-2.2.0}/src/bb_sync/commands/due.py +0 -0
- {bb_sync-2.1.0 → bb_sync-2.2.0}/src/bb_sync/commands/run.py +0 -0
- {bb_sync-2.1.0 → bb_sync-2.2.0}/src/bb_sync/context.py +0 -0
- {bb_sync-2.1.0 → bb_sync-2.2.0}/src/bb_sync/core/__init__.py +0 -0
- {bb_sync-2.1.0 → bb_sync-2.2.0}/src/bb_sync/core/config.py +0 -0
- {bb_sync-2.1.0 → bb_sync-2.2.0}/src/bb_sync/core/config_store.py +0 -0
- {bb_sync-2.1.0 → bb_sync-2.2.0}/src/bb_sync/core/doctor.py +0 -0
- {bb_sync-2.1.0 → bb_sync-2.2.0}/src/bb_sync/core/errors.py +0 -0
- {bb_sync-2.1.0 → bb_sync-2.2.0}/src/bb_sync/core/help.py +0 -0
- {bb_sync-2.1.0 → bb_sync-2.2.0}/src/bb_sync/core/output.py +0 -0
- {bb_sync-2.1.0 → bb_sync-2.2.0}/src/bb_sync/core/update.py +0 -0
- {bb_sync-2.1.0 → bb_sync-2.2.0}/src/bb_sync/creds.py +0 -0
- {bb_sync-2.1.0 → bb_sync-2.2.0}/src/bb_sync/paths.py +0 -0
- {bb_sync-2.1.0 → bb_sync-2.2.0}/tests/conftest.py +0 -0
- {bb_sync-2.1.0 → bb_sync-2.2.0}/tests/test_cli_e2e.py +0 -0
- {bb_sync-2.1.0 → bb_sync-2.2.0}/tests/test_update_e2e.py +0 -0
|
@@ -2,6 +2,37 @@
|
|
|
2
2
|
|
|
3
3
|
本项目遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
|
|
4
4
|
|
|
5
|
+
## [2.2.0] — 2026-10-31
|
|
6
|
+
|
|
7
|
+
### 修复
|
|
8
|
+
|
|
9
|
+
- **公告里的考试日期跨行也能读到**:此前只取关键词同段内的日期,遇到
|
|
10
|
+
`Midterm Exam Information` 换行、空行后才写 `Date: November 1` 的公告会整条漏掉
|
|
11
|
+
(DDA5002、MDS5020 的期中就是这样漏报的)。现在在关键词之后 240 字符内查找,
|
|
12
|
+
允许跨段,仍只取关键词之后的日期。
|
|
13
|
+
- **公告里的作业 deadline 计入待办**:新增 `assignment` 类型,逐行识别「日期 +
|
|
14
|
+
due / deadline / submit / 截止 / 提交」的截止行,并要求能认出 `HW4` /
|
|
15
|
+
`Assignment 2` 这类作业名;没名字的日期不收录。公告推断的作业逾期仍留在
|
|
16
|
+
「已逾期」(可能还没交),不像考完的考试那样归档。
|
|
17
|
+
- **To Do 与公告条目去重**:同一课程、同一天、标题指向同一件事时保留 To Do 模块
|
|
18
|
+
那份,公告推断的重复条目丢弃(如 MDS5020 的 Coding Assignment 2)。
|
|
19
|
+
- **`due.md` 内容没变时不再重写**:比较时忽略「更新时间」行,避免每次运行都产生
|
|
20
|
+
只有时间戳的 diff;终端会提示「内容未变,未重写」。
|
|
21
|
+
|
|
22
|
+
### 新增
|
|
23
|
+
|
|
24
|
+
- **`due.md` 支持勾选完成**:每条待办渲染为 GFM 任务列表 `- [ ]`,手动改成
|
|
25
|
+
`- [x]` 后下次同步按「日期 + 课程 + 标题」认回同一条并保留完成状态——签名不含
|
|
26
|
+
「还有 N 天」这类每天变化的相对时间,跨天同步也不会丢。
|
|
27
|
+
|
|
28
|
+
## [2.1.1] — 2026-10-31
|
|
29
|
+
|
|
30
|
+
### 变更
|
|
31
|
+
|
|
32
|
+
- `run` / `due` 不再逐课程打印「待办 xN,公告测验/考试 xM」:这几行与末尾的
|
|
33
|
+
「待办明细」定位重复,只有计数、看不到内容。待办结果统一在「待办明细」与
|
|
34
|
+
`due.md` 中呈现;抓取失败仍会即时告警。
|
|
35
|
+
|
|
5
36
|
## [2.1.0] — 2026-10-31
|
|
6
37
|
|
|
7
38
|
### 新增
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: bb-sync
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.2.0
|
|
4
4
|
Summary: 港中深(CUHK-Shenzhen)Blackboard 课件下载与课程资源自动同步:登录 ADFS SSO,增量下载课件/作业/指导、公告与待办
|
|
5
5
|
Project-URL: Homepage, https://github.com/asdukw/cuhk-shenzhen-blackboard-sync
|
|
6
6
|
Project-URL: Repository, https://github.com/asdukw/cuhk-shenzhen-blackboard-sync
|
|
@@ -72,7 +72,7 @@ Blackboard 上的东西散在「课程内容」「作业」「公告」几个入
|
|
|
72
72
|
|
|
73
73
|
- 课件、作业、指导按课程归好类,重跑只下新增的;
|
|
74
74
|
- 每门课的公告存成一份 `announcements.md`;
|
|
75
|
-
- 全部课程的待办汇总成一份 `due.md`,按「已逾期 → 今天 → 明天 → 未来 7 天 →
|
|
75
|
+
- 全部课程的待办汇总成一份 `due.md`,按「已逾期 → 今天 → 明天 → 未来 7 天 → 之后」排好序,公告里带日期的课堂测验、考试和作业截止时间也一并计入;每条待办都是可勾选的任务列表(`- [ ]`),做完勾上 `- [x]`,下次同步会保留完成状态。
|
|
76
76
|
|
|
77
77
|
它**不做**这些:不回传作业、不代签到、不抓成绩、不绕过任何权限——看到的就是你账号里本来能看到的。
|
|
78
78
|
|
|
@@ -217,7 +217,7 @@ bb-sync run
|
|
|
217
217
|
- 作业 → `assignments/`
|
|
218
218
|
- 指导 / 实验 → `tutorials/`
|
|
219
219
|
- 公告 → `announcements.md`
|
|
220
|
-
- 全部课程待办 → `due.md
|
|
220
|
+
- 全部课程待办 → `due.md`(按优先级排列;公告里带日期的课堂测验、考试和作业截止时间也计入,考完的测验 / 考试归入最后的「已过期事件」并保留 7 天;每条待办带 `- [ ]` 复选框,勾选状态跨次同步保留;内容没变化时不重写,没有待办时显示 Congratulations)
|
|
221
221
|
|
|
222
222
|
要修改下载目录:
|
|
223
223
|
|
|
@@ -21,7 +21,7 @@ Blackboard 上的东西散在「课程内容」「作业」「公告」几个入
|
|
|
21
21
|
|
|
22
22
|
- 课件、作业、指导按课程归好类,重跑只下新增的;
|
|
23
23
|
- 每门课的公告存成一份 `announcements.md`;
|
|
24
|
-
- 全部课程的待办汇总成一份 `due.md`,按「已逾期 → 今天 → 明天 → 未来 7 天 →
|
|
24
|
+
- 全部课程的待办汇总成一份 `due.md`,按「已逾期 → 今天 → 明天 → 未来 7 天 → 之后」排好序,公告里带日期的课堂测验、考试和作业截止时间也一并计入;每条待办都是可勾选的任务列表(`- [ ]`),做完勾上 `- [x]`,下次同步会保留完成状态。
|
|
25
25
|
|
|
26
26
|
它**不做**这些:不回传作业、不代签到、不抓成绩、不绕过任何权限——看到的就是你账号里本来能看到的。
|
|
27
27
|
|
|
@@ -166,7 +166,7 @@ bb-sync run
|
|
|
166
166
|
- 作业 → `assignments/`
|
|
167
167
|
- 指导 / 实验 → `tutorials/`
|
|
168
168
|
- 公告 → `announcements.md`
|
|
169
|
-
- 全部课程待办 → `due.md
|
|
169
|
+
- 全部课程待办 → `due.md`(按优先级排列;公告里带日期的课堂测验、考试和作业截止时间也计入,考完的测验 / 考试归入最后的「已过期事件」并保留 7 天;每条待办带 `- [ ]` 复选框,勾选状态跨次同步保留;内容没变化时不重写,没有待办时显示 Congratulations)
|
|
170
170
|
|
|
171
171
|
要修改下载目录:
|
|
172
172
|
|
|
@@ -121,9 +121,11 @@ esac
|
|
|
121
121
|
> **提示**:`bb-sync run` 的诊断信息(含「同步计划」)全部走 stderr,
|
|
122
122
|
> 所以 `--json` 模式下 stdout 依然干净,可以放心重定向。
|
|
123
123
|
>
|
|
124
|
-
> 正常模式下 `bb-sync run` 还会生成或更新 `<root>/due.md
|
|
125
|
-
>
|
|
126
|
-
>
|
|
124
|
+
> 正常模式下 `bb-sync run` 还会生成或更新 `<root>/due.md`(含公告里带日期的课堂测验、
|
|
125
|
+
> 考试与作业 deadline;考完的测验 / 考试归入最后的「已过期事件」,保留 7 天后不再收录;
|
|
126
|
+
> 每条待办是可勾选的任务列表 `- [ ]`,手动改成 `- [x]` 后下次同步会按「日期 + 课程 +
|
|
127
|
+
> 标题」认回同一条并保留勾选;内容没有变化时不重写文件,避免只产生一行「更新时间」的
|
|
128
|
+
> diff);`--dry-run` 只统计待办,不会写文件。
|
|
127
129
|
|
|
128
130
|
## 相关
|
|
129
131
|
|
|
@@ -375,6 +375,16 @@ _ANNOUNCEMENT_KIND_PATTERNS: tuple[tuple[str, re.Pattern[str]], ...] = (
|
|
|
375
375
|
("quiz", _QUIZ_RE),
|
|
376
376
|
)
|
|
377
377
|
|
|
378
|
+
#: 公告里的作业 / 项目编号(HW4、Assignment 2、Project 3)。只有能认出这类名字的
|
|
379
|
+
#: 截止行才会变成待办:一条公告里带日期的句子太多,没名字的日期分不清是什么事。
|
|
380
|
+
_ASSIGNMENT_NAME_RE = re.compile(
|
|
381
|
+
r"\b(?:homework|hw|assignment|project|lab)\s*(?:assignment\s*)?#?\s*\d{1,2}\b",
|
|
382
|
+
re.IGNORECASE,
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
#: 截止表达;与日期出现在同一行才算作业 deadline。
|
|
386
|
+
_DEADLINE_RE = re.compile(r"\b(?:due|deadline|submit|submission)\b|截止|提交", re.IGNORECASE)
|
|
387
|
+
|
|
378
388
|
#: 关键词之后最多检查这么多字符,避免把远处的日期牵强算成考试/测验日期。
|
|
379
389
|
_KEYWORD_DATE_WINDOW = 240
|
|
380
390
|
|
|
@@ -481,8 +491,11 @@ def _extract_keyword_date(
|
|
|
481
491
|
) -> date | None:
|
|
482
492
|
"""从公告里定位关键词附近的日期。
|
|
483
493
|
|
|
484
|
-
|
|
485
|
-
|
|
494
|
+
只取关键词**之后** ``_KEYWORD_DATE_WINDOW`` 个字符内的日期:日期必须写在
|
|
495
|
+
测验 / 考试名称后面,避免把关键词之前的作业截止日期误当成考试日期。
|
|
496
|
+
|
|
497
|
+
不按空行截断:常见写法是「标题一行 + 空行 + Date: …」(期中考试公告尤其如此),
|
|
498
|
+
截断到第一段会把紧跟着的考试日期丢掉——DDA5002 / MDS5020 的期中就是这样漏报的。
|
|
486
499
|
"""
|
|
487
500
|
reference = posted or date.today()
|
|
488
501
|
if pattern.search(title):
|
|
@@ -492,9 +505,6 @@ def _extract_keyword_date(
|
|
|
492
505
|
text = f"{title}\n{body}" if body else title
|
|
493
506
|
for match in pattern.finditer(text):
|
|
494
507
|
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
508
|
chosen = _pick_future_date(_find_dates(window, reference), posted)
|
|
499
509
|
if chosen:
|
|
500
510
|
return chosen
|
|
@@ -512,11 +522,18 @@ def _announcement_label_from_body(body: str, pattern: re.Pattern[str]) -> str:
|
|
|
512
522
|
def parse_announcement_due_items(
|
|
513
523
|
entries: list[dict[str, str]], course: Course, *, today: date | None = None
|
|
514
524
|
) -> list[DueItem]:
|
|
515
|
-
"""
|
|
525
|
+
"""把公告里可预期的日期整理成待办条目。
|
|
526
|
+
|
|
527
|
+
两类来源:
|
|
528
|
+
|
|
529
|
+
- **测验 / 考试**:命中关键词后取关键词之后的日期(见 :func:`_extract_keyword_date`);
|
|
530
|
+
- **作业 deadline**:逐行找同时含日期与截止表达的行,并要求行内(或上一行、
|
|
531
|
+
公告标题)能认出 ``HW4`` / ``Assignment 2`` 这类作业名。没有名字的日期太容易
|
|
532
|
+
是别的事情,宁可不收。
|
|
516
533
|
|
|
517
534
|
读不出日期、日期早于公告发布时间、或日期已过去超过 ``_PAST_EVENT_RETENTION_DAYS``
|
|
518
|
-
|
|
519
|
-
|
|
535
|
+
天的条目一律跳过:避免把「复习资料里提到 quiz」误当成待办;已经考完的测验 / 考试
|
|
536
|
+
只值得回看,不该继续占着待办位。
|
|
520
537
|
"""
|
|
521
538
|
oldest = (today or date.today()) - timedelta(days=_PAST_EVENT_RETENTION_DAYS)
|
|
522
539
|
items: list[DueItem] = []
|
|
@@ -526,32 +543,85 @@ def parse_announcement_due_items(
|
|
|
526
543
|
body = _clean_announcement_text(str(entry.get("body_markdown") or entry.get("body") or ""))
|
|
527
544
|
if not title and not body:
|
|
528
545
|
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
546
|
posted = parse_announcement_date(str(entry.get("posted_on") or ""))
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
)
|
|
547
|
+
|
|
548
|
+
matched = _announcement_kind(f"{title}\n{body}" if body else title)
|
|
549
|
+
if matched is not None:
|
|
550
|
+
kind, pattern = matched
|
|
551
|
+
due_date = _extract_keyword_date(title, body, posted, pattern)
|
|
552
|
+
if due_date is not None and due_date >= oldest:
|
|
553
|
+
label = (
|
|
554
|
+
title
|
|
555
|
+
or _announcement_label_from_body(body, pattern)
|
|
556
|
+
or course.code
|
|
557
|
+
or course.title
|
|
558
|
+
)
|
|
559
|
+
_append_announcement_item(items, seen, course, label, due_date, kind)
|
|
560
|
+
|
|
561
|
+
for due_date, label in _extract_assignment_deadlines(title, body, posted):
|
|
562
|
+
if due_date >= oldest:
|
|
563
|
+
_append_announcement_item(items, seen, course, label, due_date, "assignment")
|
|
552
564
|
return items
|
|
553
565
|
|
|
554
566
|
|
|
567
|
+
def _append_announcement_item(
|
|
568
|
+
items: list[DueItem],
|
|
569
|
+
seen: set[tuple[str, str]],
|
|
570
|
+
course: Course,
|
|
571
|
+
label: str,
|
|
572
|
+
due_date: date,
|
|
573
|
+
kind: str,
|
|
574
|
+
) -> None:
|
|
575
|
+
"""按「日期 + 标题」去重后新增条目(同一公告里两处提到同一件事只留一条)。"""
|
|
576
|
+
key = (due_date.isoformat(), label.casefold())
|
|
577
|
+
if key in seen:
|
|
578
|
+
return
|
|
579
|
+
seen.add(key)
|
|
580
|
+
items.append(
|
|
581
|
+
DueItem(
|
|
582
|
+
course_id=course.bb_id,
|
|
583
|
+
course_code=course.code,
|
|
584
|
+
course_title=course.title,
|
|
585
|
+
title=label,
|
|
586
|
+
due_date=due_date,
|
|
587
|
+
kind=kind,
|
|
588
|
+
)
|
|
589
|
+
)
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def _assignment_label(line: str, previous: str, title: str) -> str | None:
|
|
593
|
+
"""从截止行、上一行或公告标题里取出作业名;都认不出时返回 None。
|
|
594
|
+
|
|
595
|
+
上一行的兜底对应「小节标题 + 单独一行 Deadline」的写法,例如
|
|
596
|
+
``2. Coding Assignment 2 Release`` 下面才是 ``- Deadline: 23:59, November 2, 2026``。
|
|
597
|
+
"""
|
|
598
|
+
for source in (line, previous, title):
|
|
599
|
+
match = _ASSIGNMENT_NAME_RE.search(source)
|
|
600
|
+
if match:
|
|
601
|
+
return match.group(0).strip()
|
|
602
|
+
return None
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def _extract_assignment_deadlines(
|
|
606
|
+
title: str, body: str, posted: date | None
|
|
607
|
+
) -> list[tuple[date, str]]:
|
|
608
|
+
"""逐行找出「日期 + 截止表达 + 可识别作业名」的作业 deadline。"""
|
|
609
|
+
reference = posted or date.today()
|
|
610
|
+
found: list[tuple[date, str]] = []
|
|
611
|
+
previous = ""
|
|
612
|
+
for line in body.splitlines():
|
|
613
|
+
text = line.strip()
|
|
614
|
+
if not text:
|
|
615
|
+
continue
|
|
616
|
+
if _DEADLINE_RE.search(text):
|
|
617
|
+
dates = _find_dates(text, reference)
|
|
618
|
+
label = _assignment_label(text, previous, title)
|
|
619
|
+
if dates and label:
|
|
620
|
+
found.append((dates[0][1], label))
|
|
621
|
+
previous = text
|
|
622
|
+
return found
|
|
623
|
+
|
|
624
|
+
|
|
555
625
|
def _clean_announcement_text(value: str) -> str:
|
|
556
626
|
"""归一文本:压缩行尾空白,但保留段落和列表产生的换行。"""
|
|
557
627
|
text = value.replace("\xa0", " ").replace("\r\n", "\n").replace("\r", "\n")
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
"""把课程 Due / To Do 数据渲染为按优先级排列的 ``due.md``。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections import Counter, defaultdict
|
|
7
|
+
from collections.abc import Collection
|
|
8
|
+
from datetime import date, datetime, timedelta
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from bb_sync.blackboard.models import BASE, Course, DueItem
|
|
12
|
+
|
|
13
|
+
#: 分组顺序就是学生处理任务的优先级;「已过期事件」只是留档回看,所以排最后。
|
|
14
|
+
_BUCKET_ORDER = ("overdue", "today", "tomorrow", "next_7_days", "future", "unknown", "past_events")
|
|
15
|
+
_BUCKET_LABELS = {
|
|
16
|
+
"overdue": "已逾期",
|
|
17
|
+
"today": "今天",
|
|
18
|
+
"tomorrow": "明天",
|
|
19
|
+
"next_7_days": "未来 7 天",
|
|
20
|
+
"future": "之后",
|
|
21
|
+
"unknown": "日期未知",
|
|
22
|
+
"past_events": "已过期事件",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
#: 非普通任务的待办类型标签(来自公告);顺序即汇总文案里的出现顺序。
|
|
26
|
+
#: ``assignment`` 是公告里推断出的作业 deadline,和 To Do 模块的普通任务区分开。
|
|
27
|
+
_KIND_LABELS = {"quiz": "测验", "exam": "考试", "assignment": "作业"}
|
|
28
|
+
|
|
29
|
+
#: 一次性事件:测验 / 考试日期过了只能回看。作业不同——逾期可能只是还没交,
|
|
30
|
+
#: 仍然要留在「已逾期」里提醒。
|
|
31
|
+
_EVENT_KINDS = frozenset({"quiz", "exam"})
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def kind_label(kind: str) -> str:
|
|
35
|
+
"""返回待办类型的中文标签;普通任务返回空串。"""
|
|
36
|
+
return _KIND_LABELS.get(kind, "")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _is_past_event(item: DueItem, today: date) -> bool:
|
|
40
|
+
"""公告里的一次性事件(测验 / 考试)日期已过:它只能回看,没法再「做」。
|
|
41
|
+
|
|
42
|
+
与 ``overdue`` 的区别:To Do 模块的逾期任务意味着「还没交」,仍然要行动;
|
|
43
|
+
考完的测验 / 考试已是历史,混进待办里只会淹没真正要做的事。
|
|
44
|
+
"""
|
|
45
|
+
return item.kind in _EVENT_KINDS and item.due_date is not None and item.due_date < today
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _bucket(item: DueItem, today: date) -> str:
|
|
49
|
+
"""按剩余时间归类;无法解析日期的任务放最后,避免被误判成低优先级。"""
|
|
50
|
+
if item.due_date is None:
|
|
51
|
+
return "unknown"
|
|
52
|
+
if _is_past_event(item, today):
|
|
53
|
+
return "past_events"
|
|
54
|
+
if item.due_date < today:
|
|
55
|
+
return "overdue"
|
|
56
|
+
if item.due_date == today:
|
|
57
|
+
return "today"
|
|
58
|
+
if item.due_date == today + timedelta(days=1):
|
|
59
|
+
return "tomorrow"
|
|
60
|
+
if item.due_date <= today + timedelta(days=7):
|
|
61
|
+
return "next_7_days"
|
|
62
|
+
return "future"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def relative_due(item: DueItem, today: date) -> str:
|
|
66
|
+
"""生成截止日期旁的中文提示。"""
|
|
67
|
+
if item.due_date is None:
|
|
68
|
+
return item.due_text or "日期未知"
|
|
69
|
+
delta = (item.due_date - today).days
|
|
70
|
+
if delta < 0:
|
|
71
|
+
# 逾期的作业 / To Do 是「还没交」,公告里的测验 / 考试是「已经考过」,措辞分开。
|
|
72
|
+
return f"已过 {-delta} 天" if item.kind in _EVENT_KINDS else f"逾期 {-delta} 天"
|
|
73
|
+
if delta == 0:
|
|
74
|
+
return "今天"
|
|
75
|
+
if delta == 1:
|
|
76
|
+
return "明天"
|
|
77
|
+
return f"还有 {delta} 天"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _escape_markdown(text: str) -> str:
|
|
81
|
+
"""转义会改变 Markdown 结构的常见字符,同时保留可读标题。"""
|
|
82
|
+
text = re.sub(r"\s+", " ", text).strip()
|
|
83
|
+
return re.sub(r"([\\`*_\[\]])", r"\\\1", text)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _sort_key(item: DueItem) -> tuple[date, str, str]:
|
|
87
|
+
return (item.due_date or date.max, item.course_label, item.title)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def sort_due_items(items: list[DueItem]) -> list[DueItem]:
|
|
91
|
+
"""按截止日期、课程和任务名返回稳定排序的新列表。"""
|
|
92
|
+
return sorted(items, key=_sort_key)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
#: 标题分词:拉丁字母串与数字。中文标题走子串包含,见 :func:`_same_event`。
|
|
96
|
+
_TITLE_TOKEN_RE = re.compile(r"[a-z]+|\d+")
|
|
97
|
+
_CJK_RE = re.compile(r"[\u4e00-\u9fff]")
|
|
98
|
+
|
|
99
|
+
#: 不承载区分度的词,比较「是不是同一件事」时忽略。
|
|
100
|
+
_TITLE_STOPWORDS = frozenset(
|
|
101
|
+
{
|
|
102
|
+
"a",
|
|
103
|
+
"an",
|
|
104
|
+
"and",
|
|
105
|
+
"at",
|
|
106
|
+
"be",
|
|
107
|
+
"by",
|
|
108
|
+
"deadline",
|
|
109
|
+
"due",
|
|
110
|
+
"for",
|
|
111
|
+
"in",
|
|
112
|
+
"is",
|
|
113
|
+
"new",
|
|
114
|
+
"of",
|
|
115
|
+
"on",
|
|
116
|
+
"release",
|
|
117
|
+
"released",
|
|
118
|
+
"reminder",
|
|
119
|
+
"the",
|
|
120
|
+
"to",
|
|
121
|
+
"update",
|
|
122
|
+
"updated",
|
|
123
|
+
}
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _title_tokens(text: str) -> set[str]:
|
|
128
|
+
"""标题里可用于判重的实义词;纯数字不算(「HW 2」与「Quiz 2」不是同一件事)。"""
|
|
129
|
+
return {
|
|
130
|
+
token
|
|
131
|
+
for token in _TITLE_TOKEN_RE.findall(text.casefold())
|
|
132
|
+
if token not in _TITLE_STOPWORDS and not token.isdigit()
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _compact(text: str) -> str:
|
|
137
|
+
"""去掉空格与标点,便于中文标题做包含判断。"""
|
|
138
|
+
return re.sub(r"[^0-9a-z\u4e00-\u9fff]+", "", text.casefold())
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _same_event(left: DueItem, right: DueItem) -> bool:
|
|
142
|
+
"""同一课程、同一天、标题指向同一件事 → 视为重复条目。"""
|
|
143
|
+
same_course = bool(
|
|
144
|
+
(left.course_id and left.course_id == right.course_id)
|
|
145
|
+
or (left.course_code and left.course_code == right.course_code)
|
|
146
|
+
)
|
|
147
|
+
if not same_course or left.due_date is None or left.due_date != right.due_date:
|
|
148
|
+
return False
|
|
149
|
+
if _CJK_RE.search(left.title) or _CJK_RE.search(right.title):
|
|
150
|
+
a, b = _compact(left.title), _compact(right.title)
|
|
151
|
+
return bool(a and b and (a in b or b in a))
|
|
152
|
+
return bool(_title_tokens(left.title) & _title_tokens(right.title))
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def merge_due_items(todo_items: list[DueItem], announcement_items: list[DueItem]) -> list[DueItem]:
|
|
156
|
+
"""合并 To Do 模块与公告推断的待办,去掉同一件事的重复条目。
|
|
157
|
+
|
|
158
|
+
To Do 模块是站点的原生数据,说明更准确(标题、逾期状态都来自 Blackboard),
|
|
159
|
+
所以冲突时保留它;公告条目只在没有对应 To Do 时补进来。
|
|
160
|
+
"""
|
|
161
|
+
merged = list(todo_items)
|
|
162
|
+
merged.extend(
|
|
163
|
+
item
|
|
164
|
+
for item in announcement_items
|
|
165
|
+
if not any(_same_event(item, existing) for existing in todo_items)
|
|
166
|
+
)
|
|
167
|
+
return merged
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
#: due.md 里复选框行的形状:``- [ ] …`` / ``- [x] …``。
|
|
171
|
+
_CHECKBOX_LINE_RE = re.compile(r"^- \[(?P<mark>[ xX])\] (?P<body>.*)$")
|
|
172
|
+
|
|
173
|
+
#: 行内的相对时间(如「还有 3 天」);每天都会变,不参与勾选状态的匹配。
|
|
174
|
+
_RELATIVE_TIME_RE = re.compile(r"([^)]*)")
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _item_signature(item: DueItem) -> str:
|
|
178
|
+
"""条目的稳定签名:日期 + 课程 + 标题,不含随日期变化的相对时间。
|
|
179
|
+
|
|
180
|
+
用户在 due.md 里勾选后,下次运行靠它认回同一条待办并保留 ``[x]``。
|
|
181
|
+
"""
|
|
182
|
+
due = item.due_date.isoformat() if item.due_date else "日期未知"
|
|
183
|
+
course_url = f"{BASE}/webapps/blackboard/execute/launcher?type=Course&id={item.course_id}"
|
|
184
|
+
label = kind_label(item.kind)
|
|
185
|
+
badge = f"【{label}】" if label else ""
|
|
186
|
+
return (
|
|
187
|
+
f"**{due}** — [{_escape_markdown(item.course_label)}]({course_url}) — "
|
|
188
|
+
f"{badge}{_escape_markdown(item.title)}"
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _checked_signatures(markdown: str) -> set[str]:
|
|
193
|
+
"""从现有 due.md 读出已勾选条目的签名。"""
|
|
194
|
+
done: set[str] = set()
|
|
195
|
+
for line in markdown.splitlines():
|
|
196
|
+
match = _CHECKBOX_LINE_RE.match(line)
|
|
197
|
+
if not match or match.group("mark").lower() != "x":
|
|
198
|
+
continue
|
|
199
|
+
done.add(_RELATIVE_TIME_RE.sub("", match.group("body"), count=1))
|
|
200
|
+
return done
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def build_due_markdown(
|
|
204
|
+
courses: list[Course],
|
|
205
|
+
items: list[DueItem],
|
|
206
|
+
*,
|
|
207
|
+
failed: int = 0,
|
|
208
|
+
today: date | None = None,
|
|
209
|
+
generated_at: datetime | None = None,
|
|
210
|
+
done: Collection[str] = (),
|
|
211
|
+
) -> str:
|
|
212
|
+
"""生成全局 ``due.md`` 文本。
|
|
213
|
+
|
|
214
|
+
输出按「已逾期 → 今天 → 明天 → 未来 7 天 → 之后 → 日期未知 → 已过期事件」排序;
|
|
215
|
+
同一分组内再按日期、课程和任务名稳定排序。
|
|
216
|
+
|
|
217
|
+
``done`` 是已勾选条目的签名集合(见 :func:`_item_signature`),命中的条目会渲染成
|
|
218
|
+
``- [x]``;调用方通常从上一版 due.md 里读出它,从而跨次同步保留完成状态。
|
|
219
|
+
"""
|
|
220
|
+
day = today or date.today()
|
|
221
|
+
generated = generated_at or datetime.now().astimezone()
|
|
222
|
+
grouped: dict[str, list[DueItem]] = defaultdict(list)
|
|
223
|
+
for item in items:
|
|
224
|
+
grouped[_bucket(item, day)].append(item)
|
|
225
|
+
for bucket_items in grouped.values():
|
|
226
|
+
bucket_items.sort(key=_sort_key)
|
|
227
|
+
|
|
228
|
+
# 过期事件单独成区,汇总行也不把它算进「待办」,否则「测验 2 项」里混着考完的。
|
|
229
|
+
past_events = grouped.get("past_events", [])
|
|
230
|
+
kind_counts = Counter(item.kind for item in items if not _is_past_event(item, day))
|
|
231
|
+
# 只统计公告整理出的待办,按 _KIND_LABELS 的固定顺序列出(测验 → 考试)。
|
|
232
|
+
announcement_parts = [
|
|
233
|
+
f"{label} {kind_counts[kind]} 项"
|
|
234
|
+
for kind, label in _KIND_LABELS.items()
|
|
235
|
+
if kind_counts[kind]
|
|
236
|
+
]
|
|
237
|
+
lines = [
|
|
238
|
+
"# Blackboard 待办",
|
|
239
|
+
"",
|
|
240
|
+
f"> 更新时间:{generated.strftime('%Y-%m-%d %H:%M')}(本地时间)",
|
|
241
|
+
f"> 已检查 {len(courses)} 门课程,发现 {len(items)} 项待办;按优先级和截止日期排列。",
|
|
242
|
+
]
|
|
243
|
+
if announcement_parts:
|
|
244
|
+
lines.append(f"> 其中公告里的{'、'.join(announcement_parts)}已一并作为待办列出。")
|
|
245
|
+
if past_events:
|
|
246
|
+
lines.append(f"> 另有 {len(past_events)} 项公告里的测验/考试日期已过,列在最后仅供回看。")
|
|
247
|
+
if failed:
|
|
248
|
+
lines.append(f"> 注意:{failed} 门课程抓取失败,结果可能不完整。")
|
|
249
|
+
if items:
|
|
250
|
+
lines.append("> 完成一项就在前面打勾(`- [ ]` → `- [x]`),下次同步会保留完成状态。")
|
|
251
|
+
lines.append("")
|
|
252
|
+
|
|
253
|
+
if not items:
|
|
254
|
+
if failed:
|
|
255
|
+
lines.extend(["本次未读取到待办,但由于部分课程抓取失败,结果可能不完整。", ""])
|
|
256
|
+
else:
|
|
257
|
+
lines.extend(["## Congratulations! 🎉", "", "当前没有待办事项。", ""])
|
|
258
|
+
return "\n".join(lines)
|
|
259
|
+
|
|
260
|
+
for bucket in _BUCKET_ORDER:
|
|
261
|
+
bucket_items = grouped.get(bucket, [])
|
|
262
|
+
if not bucket_items:
|
|
263
|
+
continue
|
|
264
|
+
lines.append(f"## {_BUCKET_LABELS[bucket]}({len(bucket_items)})")
|
|
265
|
+
lines.append("")
|
|
266
|
+
for item in bucket_items:
|
|
267
|
+
due = item.due_date.isoformat() if item.due_date else "日期未知"
|
|
268
|
+
course_url = (
|
|
269
|
+
f"{BASE}/webapps/blackboard/execute/launcher?type=Course&id={item.course_id}"
|
|
270
|
+
)
|
|
271
|
+
label = kind_label(item.kind)
|
|
272
|
+
badge = f"【{label}】" if label else ""
|
|
273
|
+
box = "[x]" if _item_signature(item) in done else "[ ]"
|
|
274
|
+
lines.append(
|
|
275
|
+
f"- {box} **{due}**({relative_due(item, day)}) — "
|
|
276
|
+
f"[{_escape_markdown(item.course_label)}]({course_url}) — "
|
|
277
|
+
f"{badge}{_escape_markdown(item.title)}"
|
|
278
|
+
)
|
|
279
|
+
lines.append("")
|
|
280
|
+
return "\n".join(lines)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
#: 头部「更新时间」行的前缀;比较内容是否变化时先把它去掉。
|
|
284
|
+
_UPDATE_LINE_PREFIX = "> 更新时间:"
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _without_timestamp(text: str) -> str:
|
|
288
|
+
"""去掉头部的更新时间行,用于判断待办内容本身有没有变化。"""
|
|
289
|
+
return "\n".join(line for line in text.splitlines() if not line.startswith(_UPDATE_LINE_PREFIX))
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def write_due_markdown(
|
|
293
|
+
path: Path,
|
|
294
|
+
courses: list[Course],
|
|
295
|
+
items: list[DueItem],
|
|
296
|
+
*,
|
|
297
|
+
failed: int = 0,
|
|
298
|
+
today: date | None = None,
|
|
299
|
+
generated_at: datetime | None = None,
|
|
300
|
+
) -> bool:
|
|
301
|
+
"""写入 ``due.md``;父目录不存在时自动创建。返回是否真的写了文件。
|
|
302
|
+
|
|
303
|
+
内容(除更新时间外)和现有文件一样时保持原样:每次运行都重写会把「更新时间」
|
|
304
|
+
一起改掉,即使待办一条没变,git 里也多出一份只有时间戳的 diff。
|
|
305
|
+
"""
|
|
306
|
+
previous = ""
|
|
307
|
+
if path.exists():
|
|
308
|
+
try:
|
|
309
|
+
previous = path.read_text(encoding="utf-8")
|
|
310
|
+
except OSError:
|
|
311
|
+
previous = ""
|
|
312
|
+
content = build_due_markdown(
|
|
313
|
+
courses,
|
|
314
|
+
items,
|
|
315
|
+
failed=failed,
|
|
316
|
+
today=today,
|
|
317
|
+
generated_at=generated_at,
|
|
318
|
+
# 用户在上一版里勾选的条目,重新生成时保持 [x]
|
|
319
|
+
done=_checked_signatures(previous),
|
|
320
|
+
)
|
|
321
|
+
if previous and _without_timestamp(previous) == _without_timestamp(content):
|
|
322
|
+
return False
|
|
323
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
324
|
+
path.write_text(content, encoding="utf-8")
|
|
325
|
+
return True
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
__all__ = [
|
|
329
|
+
"build_due_markdown",
|
|
330
|
+
"kind_label",
|
|
331
|
+
"merge_due_items",
|
|
332
|
+
"relative_due",
|
|
333
|
+
"sort_due_items",
|
|
334
|
+
"write_due_markdown",
|
|
335
|
+
]
|