monash-study-kit 1.2.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.
- monash_study_kit/__init__.py +4 -0
- monash_study_kit/__main__.py +5 -0
- monash_study_kit/browser_login.py +213 -0
- monash_study_kit/cdp.py +188 -0
- monash_study_kit/claude_setup.py +152 -0
- monash_study_kit/cli.py +843 -0
- monash_study_kit/cli_ed.py +238 -0
- monash_study_kit/clients.py +230 -0
- monash_study_kit/content_index.py +324 -0
- monash_study_kit/doctor.py +228 -0
- monash_study_kit/doctor_deep.py +275 -0
- monash_study_kit/edlib.py +551 -0
- monash_study_kit/edquery.py +431 -0
- monash_study_kit/edsync.py +183 -0
- monash_study_kit/features.py +591 -0
- monash_study_kit/help_menu.py +121 -0
- monash_study_kit/htmldom.py +162 -0
- monash_study_kit/jobs.py +233 -0
- monash_study_kit/lesson_reader.py +94 -0
- monash_study_kit/lessons.py +231 -0
- monash_study_kit/mcp_server.py +713 -0
- monash_study_kit/moodlelib.py +485 -0
- monash_study_kit/output.py +73 -0
- monash_study_kit/paths.py +126 -0
- monash_study_kit/recordings.py +312 -0
- monash_study_kit/syncer.py +393 -0
- monash_study_kit/textextract.py +89 -0
- monash_study_kit/todo.py +178 -0
- monash_study_kit/transcribe.py +128 -0
- monash_study_kit/update_check.py +113 -0
- monash_study_kit/webnotes.py +312 -0
- monash_study_kit-1.2.0.dist-info/METADATA +464 -0
- monash_study_kit-1.2.0.dist-info/RECORD +36 -0
- monash_study_kit-1.2.0.dist-info/WHEEL +4 -0
- monash_study_kit-1.2.0.dist-info/entry_points.txt +2 -0
- monash_study_kit-1.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
"""把一门课的 Moodle 内容同步成 课程/周次 的目录。
|
|
2
|
+
|
|
3
|
+
FIT2102 Programming paradigms (S2 2026)/
|
|
4
|
+
Week 01 - Introduction to Functional Programming in JavaScript/
|
|
5
|
+
applied1.zip
|
|
6
|
+
links.md ← 本周的外部链接(Slides、YouTube、课程笔记…)
|
|
7
|
+
Assessments/2. Written/
|
|
8
|
+
A1 spec.pdf ← 作业说明里的附件
|
|
9
|
+
|
|
10
|
+
目录结构来自 core_courseformat_get_state:Monash 的每周是一个 "Week N - 标题"
|
|
11
|
+
章节,下面挂 Own-time / Real-time / Wrap-up 子章节(`parent` 指向周章节)。
|
|
12
|
+
子章节不单独建目录,文件直接放进周目录里,按周浏览更顺手。
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import html
|
|
17
|
+
import re
|
|
18
|
+
import urllib.parse
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
|
|
21
|
+
from .moodlelib import (FILES_DIR, HOST, MoodleClient, collect_links, filename_from,
|
|
22
|
+
is_attachment, now_iso)
|
|
23
|
+
from .paths import load_settings
|
|
24
|
+
|
|
25
|
+
WEEK_RE = re.compile(r"^\s*week\s*0*(\d+)\b\s*[-:–—]?\s*(.*)$", re.I)
|
|
26
|
+
CODE_RE = re.compile(r"\b([A-Z]{3}\d{4})\b")
|
|
27
|
+
SUFFIX_RE = re.compile(r"\s*-\s*((?:MUM\s+)?(?:S[12]|Summer|Winter)\b.*?\d{4}.*|(?:Summer|Winter)\s+MUM\s+\d{4})\s*$",
|
|
28
|
+
re.I)
|
|
29
|
+
VIDEO_EXT = (".mp4", ".mov", ".m4v", ".webm", ".mkv")
|
|
30
|
+
# 这些活动里没有能下载的东西(论坛、测验、外部工具…),也不去点开它们
|
|
31
|
+
SKIP_MODULES = {"forum", "quiz", "lti", "shadow", "feedback", "choice", "attendance",
|
|
32
|
+
"turnitintooltwo", "zoom", "h5pactivity", "scorm", "questionnaire", "workshop"}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def clean(text: str) -> str:
|
|
36
|
+
return " ".join(html.unescape(text or "").split())
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
NAME_LIMIT = int(load_settings().get("name_limit") or 80)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def safe_name(name: str, limit: int | None = None) -> str:
|
|
43
|
+
"""能当文件/目录名用。去掉路径分隔符和 Windows 不认的字符,截短但保留扩展名。
|
|
44
|
+
|
|
45
|
+
默认上限 80(设置里的 name_limit):Windows 默认整条路径不能超过 260 个字符,
|
|
46
|
+
课程/周次/文件夹/文件四层叠起来很容易超。只在 Linux 服务器上跑的部署可以放宽,
|
|
47
|
+
但已经同步过的库别改,改了文件名会变,旧文件会被当成另一份再下一遍。
|
|
48
|
+
"""
|
|
49
|
+
limit = limit or NAME_LIMIT
|
|
50
|
+
name = clean(name)
|
|
51
|
+
name = re.sub(r'[\\/:*?"<>|\x00-\x1f]', " ", name)
|
|
52
|
+
name = re.sub(r"\s+", " ", name).strip(" .")
|
|
53
|
+
if len(name) > limit:
|
|
54
|
+
stem, dot, ext = name.rpartition(".")
|
|
55
|
+
if dot and 0 < len(ext) <= 8:
|
|
56
|
+
name = stem[: limit - len(ext) - 1].rstrip() + "." + ext
|
|
57
|
+
else:
|
|
58
|
+
name = name[:limit].rstrip()
|
|
59
|
+
return name or "untitled"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def course_code(course: dict) -> str | None:
|
|
63
|
+
m = CODE_RE.search(clean(course.get("fullname", ""))) or CODE_RE.search(course.get("shortname", ""))
|
|
64
|
+
return m.group(1) if m else None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def course_folder(course: dict) -> str:
|
|
68
|
+
""""FIT2102 Programming paradigms - S2 2026" → "FIT2102 Programming paradigms (S2 2026)"."""
|
|
69
|
+
full = clean(course.get("fullname", "")) or course.get("shortname", "") or str(course["id"])
|
|
70
|
+
m = SUFFIX_RE.search(full)
|
|
71
|
+
if m:
|
|
72
|
+
full = f"{full[:m.start()].strip()} ({m.group(1).strip()})"
|
|
73
|
+
return safe_name(full)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def week_folder(title: str) -> str | None:
|
|
77
|
+
m = WEEK_RE.match(clean(title))
|
|
78
|
+
if not m:
|
|
79
|
+
return None
|
|
80
|
+
rest = m.group(2).strip()
|
|
81
|
+
return safe_name(f"Week {int(m.group(1)):02d}" + (f" - {rest}" if rest else ""))
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass
|
|
85
|
+
class Layout:
|
|
86
|
+
"""章节号 → 相对课程目录的文件夹。"""
|
|
87
|
+
folders: dict[int, str] = field(default_factory=dict)
|
|
88
|
+
titles: dict[int, str] = field(default_factory=dict)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def plan_layout(state: dict) -> Layout:
|
|
92
|
+
sections = {s["number"]: s for s in state["section"]}
|
|
93
|
+
|
|
94
|
+
def chain(num: int) -> list[dict]:
|
|
95
|
+
out, seen = [], set()
|
|
96
|
+
while num is not None and num in sections and num not in seen:
|
|
97
|
+
seen.add(num)
|
|
98
|
+
out.append(sections[num])
|
|
99
|
+
num = sections[num].get("parent")
|
|
100
|
+
return list(reversed(out))
|
|
101
|
+
|
|
102
|
+
layout = Layout()
|
|
103
|
+
for num, sec in sections.items():
|
|
104
|
+
layout.titles[num] = clean(sec.get("title"))
|
|
105
|
+
path = chain(num)
|
|
106
|
+
week = next((week_folder(s["title"]) for s in reversed(path) if week_folder(s["title"])), None)
|
|
107
|
+
if week:
|
|
108
|
+
layout.folders[num] = week
|
|
109
|
+
continue
|
|
110
|
+
# 非周次的章节:保留层级,但跳过只当容器的上级:自己没有内容的(比如 "Learning"),
|
|
111
|
+
# 以及第 0 节(Monash 叫 "Unit dashboard",Assessments 之类都挂在它下面)
|
|
112
|
+
parts = [safe_name(s["title"]) for i, s in enumerate(path)
|
|
113
|
+
if i == len(path) - 1 or (s.get("cmlist") and s["number"] != 0)]
|
|
114
|
+
layout.folders[num] = "/".join(parts) or "General"
|
|
115
|
+
return layout
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@dataclass
|
|
119
|
+
class SyncStats:
|
|
120
|
+
downloaded: int = 0
|
|
121
|
+
skipped: int = 0
|
|
122
|
+
links: int = 0
|
|
123
|
+
removed: int = 0
|
|
124
|
+
errors: list[str] = field(default_factory=list)
|
|
125
|
+
bytes: int = 0
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class CourseSync:
|
|
129
|
+
def __init__(self, client: MoodleClient, con, course: dict, *, dry_run: bool = False, log=print):
|
|
130
|
+
self.c = client
|
|
131
|
+
self.con = con
|
|
132
|
+
self.course = course
|
|
133
|
+
self.cid = int(course["id"])
|
|
134
|
+
self.root = course_folder(course)
|
|
135
|
+
self.dry = dry_run
|
|
136
|
+
self.log = log
|
|
137
|
+
self.stats = SyncStats()
|
|
138
|
+
self.seen_sources: set[str] = set()
|
|
139
|
+
self.videos = load_settings().get("download_videos", False)
|
|
140
|
+
|
|
141
|
+
# ------------------------------------------------------------ 入口
|
|
142
|
+
|
|
143
|
+
def run(self) -> SyncStats:
|
|
144
|
+
state = self.c.course_state(self.cid)
|
|
145
|
+
layout = plan_layout(state)
|
|
146
|
+
cms = {str(cm["id"]): cm for cm in state["cm"]}
|
|
147
|
+
sections = {s["number"]: s for s in state["section"]}
|
|
148
|
+
if not self.dry:
|
|
149
|
+
self.clean_partials()
|
|
150
|
+
|
|
151
|
+
for num in sorted(sections):
|
|
152
|
+
for cmid in sections[num].get("cmlist") or []:
|
|
153
|
+
cm = cms.get(str(cmid))
|
|
154
|
+
if not cm or cm.get("uservisible") is False:
|
|
155
|
+
continue
|
|
156
|
+
folder = layout.folders.get(cm.get("sectionnumber", num), "General")
|
|
157
|
+
try:
|
|
158
|
+
self.handle_cm(cm, folder, layout.titles.get(num, ""))
|
|
159
|
+
except Exception as e: # noqa: BLE001 一个坏活动不该拖垮整门课
|
|
160
|
+
if e.__class__.__name__ == "MoodleAuthError":
|
|
161
|
+
raise
|
|
162
|
+
self.stats.errors.append(f"{cm.get('name')} ({cm['id']}): {e}")
|
|
163
|
+
self.log(f" ! {cm.get('name')}: {e}")
|
|
164
|
+
|
|
165
|
+
# label、CMS 块和每个活动的说明文字只在课程页 HTML 里
|
|
166
|
+
self.scan_section_pages(state, layout, cms)
|
|
167
|
+
if not self.dry:
|
|
168
|
+
self.mark_removed()
|
|
169
|
+
self.con.execute("UPDATE courses SET synced_at=? WHERE id=?", (now_iso(), self.cid))
|
|
170
|
+
self.con.commit()
|
|
171
|
+
return self.stats
|
|
172
|
+
|
|
173
|
+
def clean_partials(self) -> None:
|
|
174
|
+
"""清掉以前中断的下载留下的临时文件(.dl-*,1.0.3 及以前还会是 .dl-*.part.part)。
|
|
175
|
+
|
|
176
|
+
同步是一门一门串行跑的,跑到这门课时不会有正在下的文件。
|
|
177
|
+
"""
|
|
178
|
+
root = FILES_DIR / self.root
|
|
179
|
+
if root.exists():
|
|
180
|
+
for p in root.rglob(".dl-*"):
|
|
181
|
+
if p.is_file():
|
|
182
|
+
p.unlink(missing_ok=True)
|
|
183
|
+
|
|
184
|
+
def mark_removed(self) -> None:
|
|
185
|
+
"""这一轮没再见到的文件标成已移除(老师下架了,或者换了个文件名重新上传)。
|
|
186
|
+
|
|
187
|
+
只有整门课一个错误都没有时才标:有活动请求失败的话,没见到不代表没了。
|
|
188
|
+
只管同步器自己下的(cmid 不为空),讲义网页和录播不归这里管。文件本身不删。
|
|
189
|
+
"""
|
|
190
|
+
if self.stats.errors:
|
|
191
|
+
return
|
|
192
|
+
rows = self.con.execute("SELECT source, path, removed_at FROM files WHERE course_id=? AND cmid IS NOT NULL",
|
|
193
|
+
(self.cid,)).fetchall()
|
|
194
|
+
now = now_iso()
|
|
195
|
+
for r in rows:
|
|
196
|
+
seen = r["source"] in self.seen_sources
|
|
197
|
+
# 视频的字幕稿(transcribe 登记成 transcript:<视频路径>)跟着视频一起标
|
|
198
|
+
sources = (r["source"], f"transcript:{r['path']}")
|
|
199
|
+
if seen and r["removed_at"]:
|
|
200
|
+
self.con.execute("UPDATE files SET removed_at=NULL WHERE course_id=? AND source IN (?,?)",
|
|
201
|
+
(self.cid, *sources))
|
|
202
|
+
elif not seen and not r["removed_at"]:
|
|
203
|
+
self.con.execute("UPDATE files SET removed_at=? WHERE course_id=? AND source IN (?,?)",
|
|
204
|
+
(now, self.cid, *sources))
|
|
205
|
+
self.stats.removed += 1
|
|
206
|
+
self.con.commit()
|
|
207
|
+
|
|
208
|
+
# ------------------------------------------------------------ 各类活动
|
|
209
|
+
|
|
210
|
+
def handle_cm(self, cm: dict, folder: str, section_title: str) -> None:
|
|
211
|
+
mod = cm.get("module")
|
|
212
|
+
url = cm.get("url")
|
|
213
|
+
if mod in SKIP_MODULES or not url:
|
|
214
|
+
return
|
|
215
|
+
if mod == "resource":
|
|
216
|
+
local, external, resp = self.c.resolve(url + "&redirect=1")
|
|
217
|
+
if external is None and resp.status == 200 and "text/html" in (resp.headers.get("Content-Type") or ""):
|
|
218
|
+
# 设成"嵌入显示"的资源不跳转,文件链接在页面里
|
|
219
|
+
atts = [h for h, _ in collect_links(resp.text()).all if is_attachment(h)]
|
|
220
|
+
for h in atts[:1]:
|
|
221
|
+
self.fetch_file(h, folder, cm, section_title)
|
|
222
|
+
return
|
|
223
|
+
self.fetch_file(local, folder, cm, section_title, resolved=True)
|
|
224
|
+
elif mod == "folder":
|
|
225
|
+
page = self.c.html(url)
|
|
226
|
+
sub = f"{folder}/{safe_name(cm['name'])}"
|
|
227
|
+
for h, _ in collect_links(page).all:
|
|
228
|
+
if "/mod_folder/content/" in h:
|
|
229
|
+
self.fetch_file(h, sub, cm, section_title)
|
|
230
|
+
elif mod == "url":
|
|
231
|
+
local, external, _ = self.c.resolve(url + "&redirect=1")
|
|
232
|
+
target = external or local
|
|
233
|
+
if urllib.parse.urlsplit(target).hostname == HOST and "/pluginfile.php/" in target:
|
|
234
|
+
self.fetch_file(target, folder, cm, section_title)
|
|
235
|
+
else:
|
|
236
|
+
self.add_link(folder, section_title, clean(cm["name"]), target)
|
|
237
|
+
elif mod in ("assign", "page", "book"):
|
|
238
|
+
# 作业说明、页面里挂的附件(spec PDF、starter code)才是要的东西
|
|
239
|
+
page = self.c.html(url)
|
|
240
|
+
for h, _ in collect_links(page).all:
|
|
241
|
+
if is_attachment(h) and f"/{self._context_hint(mod)}" in h:
|
|
242
|
+
self.fetch_file(h, folder, cm, section_title)
|
|
243
|
+
self.add_link(folder, section_title, clean(cm["name"]), url)
|
|
244
|
+
|
|
245
|
+
@staticmethod
|
|
246
|
+
def _context_hint(mod: str) -> str:
|
|
247
|
+
return {"assign": "mod_assign/", "page": "mod_page/", "book": "mod_book/"}[mod]
|
|
248
|
+
|
|
249
|
+
def scan_section_pages(self, state: dict, layout: Layout, cms: dict) -> None:
|
|
250
|
+
pages: dict[str, None] = {}
|
|
251
|
+
for sec in state["section"]:
|
|
252
|
+
if not sec.get("cmlist") or not sec.get("sectionurl"):
|
|
253
|
+
continue
|
|
254
|
+
pages[sec["sectionurl"].split("#", 1)[0]] = None
|
|
255
|
+
for page_url in pages:
|
|
256
|
+
try:
|
|
257
|
+
found = collect_links(self.c.html(page_url)).by_activity
|
|
258
|
+
except Exception as e: # noqa: BLE001
|
|
259
|
+
if e.__class__.__name__ == "MoodleAuthError":
|
|
260
|
+
raise
|
|
261
|
+
self.stats.errors.append(f"{page_url}: {e}")
|
|
262
|
+
continue
|
|
263
|
+
for cmid, links in found.items():
|
|
264
|
+
cm = cms.get(cmid)
|
|
265
|
+
if not cm:
|
|
266
|
+
continue
|
|
267
|
+
num = cm.get("sectionnumber")
|
|
268
|
+
folder = layout.folders.get(num, "General")
|
|
269
|
+
title = layout.titles.get(num, "")
|
|
270
|
+
for href, text in links:
|
|
271
|
+
if is_attachment(href):
|
|
272
|
+
try:
|
|
273
|
+
self.fetch_file(href, folder, cm, title)
|
|
274
|
+
except Exception as e: # noqa: BLE001
|
|
275
|
+
if e.__class__.__name__ == "MoodleAuthError":
|
|
276
|
+
raise
|
|
277
|
+
self.stats.errors.append(f"{href}: {e}")
|
|
278
|
+
elif is_external(href):
|
|
279
|
+
label = text or clean(cm.get("name"))
|
|
280
|
+
self.add_link(folder, title, label, href)
|
|
281
|
+
|
|
282
|
+
# ------------------------------------------------------------ 落盘
|
|
283
|
+
|
|
284
|
+
def fetch_file(self, url: str, folder: str, cm: dict, section_title: str, resolved: bool = False) -> None:
|
|
285
|
+
# pluginfile 地址本身就带 revision,不用再请求一次;其它的要跟一下跳转才知道
|
|
286
|
+
if resolved or "/pluginfile.php/" in url:
|
|
287
|
+
source = url.split("?", 1)[0]
|
|
288
|
+
else:
|
|
289
|
+
source = self.c.resolve(url)[0].split("?", 1)[0]
|
|
290
|
+
if source in self.seen_sources:
|
|
291
|
+
return
|
|
292
|
+
self.seen_sources.add(source)
|
|
293
|
+
row = self.con.execute("SELECT path FROM files WHERE course_id=? AND source=?",
|
|
294
|
+
(self.cid, source)).fetchone()
|
|
295
|
+
if row and (FILES_DIR / row["path"]).exists():
|
|
296
|
+
self.stats.skipped += 1
|
|
297
|
+
return
|
|
298
|
+
guess = urllib.parse.unquote(urllib.parse.urlsplit(source).path.rsplit("/", 1)[-1])
|
|
299
|
+
if not self.videos and guess.lower().endswith(VIDEO_EXT):
|
|
300
|
+
# 默认不下录像:记成链接,在 links.md 里能点开看(monash config download_videos true 改)
|
|
301
|
+
self.add_link(folder, section_title, f"🎬 {clean(cm.get('name')) or guess}", source)
|
|
302
|
+
return
|
|
303
|
+
rel_dir = f"{self.root}/{folder}"
|
|
304
|
+
if self.dry:
|
|
305
|
+
self.log(f" + {rel_dir}/{safe_name(guess)}")
|
|
306
|
+
self.stats.downloaded += 1
|
|
307
|
+
return
|
|
308
|
+
tmp = FILES_DIR / rel_dir / f".dl-{cm['id']}" # 下载时 client.download 自己会加 .part
|
|
309
|
+
resp = self.c.download(source, tmp)
|
|
310
|
+
name = safe_name(filename_from(resp, guess) or guess)
|
|
311
|
+
rel = self._unique(f"{rel_dir}/{name}", source)
|
|
312
|
+
dest = FILES_DIR / rel
|
|
313
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
314
|
+
tmp.replace(dest)
|
|
315
|
+
size = dest.stat().st_size
|
|
316
|
+
self.con.execute(
|
|
317
|
+
"INSERT INTO files (course_id, source, cmid, path, size, section, title, synced_at)"
|
|
318
|
+
" VALUES (?,?,?,?,?,?,?,?) ON CONFLICT(course_id, source) DO UPDATE SET"
|
|
319
|
+
" path=excluded.path, size=excluded.size, synced_at=excluded.synced_at",
|
|
320
|
+
(self.cid, source, int(cm["id"]), rel, size, section_title, clean(cm.get("name")), now_iso()))
|
|
321
|
+
self.con.commit()
|
|
322
|
+
self.stats.downloaded += 1
|
|
323
|
+
self.stats.bytes += size
|
|
324
|
+
self.log(f" ↓ {rel} ({size // 1024} KB)")
|
|
325
|
+
|
|
326
|
+
def _unique(self, rel: str, source: str) -> str:
|
|
327
|
+
"""同名文件换了内容(revision 变了)或者旧的已经下架,就覆盖旧的;别的来源撞名才加序号。"""
|
|
328
|
+
stem, dot, ext = rel.rpartition(".")
|
|
329
|
+
if not dot or "/" in ext:
|
|
330
|
+
stem, ext = rel, ""
|
|
331
|
+
n, cand = 1, rel
|
|
332
|
+
while True:
|
|
333
|
+
owner = self.con.execute("SELECT source, removed_at FROM files WHERE course_id=? AND path=?",
|
|
334
|
+
(self.cid, cand)).fetchone()
|
|
335
|
+
# 已经下架的旧文件占着这个名字,也直接让给新文件
|
|
336
|
+
if owner is None or owner["source"] == source or owner["removed_at"] or _same_file(owner["source"], source):
|
|
337
|
+
if owner is not None and owner["source"] != source:
|
|
338
|
+
self.con.execute("DELETE FROM files WHERE course_id=? AND source=?",
|
|
339
|
+
(self.cid, owner["source"]))
|
|
340
|
+
return cand
|
|
341
|
+
n += 1
|
|
342
|
+
cand = f"{stem} ({n}).{ext}" if ext else f"{stem} ({n})"
|
|
343
|
+
|
|
344
|
+
def add_link(self, folder: str, section: str, title: str, url: str) -> None:
|
|
345
|
+
if self.dry:
|
|
346
|
+
return
|
|
347
|
+
cur = self.con.execute(
|
|
348
|
+
"INSERT OR IGNORE INTO links (course_id, folder, section, title, url, synced_at)"
|
|
349
|
+
" VALUES (?,?,?,?,?,?)", (self.cid, folder, section, title, url, now_iso()))
|
|
350
|
+
self.stats.links += cur.rowcount
|
|
351
|
+
self.con.commit()
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _same_file(a: str, b: str) -> bool:
|
|
355
|
+
"""pluginfile 地址去掉 revision 那一段后相同,就是同一个文件的新版本。"""
|
|
356
|
+
strip = lambda u: re.sub(r"/content/\d+/", "/content/X/", urllib.parse.urlsplit(u).path) # noqa: E731
|
|
357
|
+
return strip(a) == strip(b)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
IGNORED_HOSTS = ("okta.com", "monash.edu/students", "google.com/url")
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def is_external(href: str) -> bool:
|
|
364
|
+
parts = urllib.parse.urlsplit(href)
|
|
365
|
+
if parts.scheme not in ("http", "https") or not parts.hostname:
|
|
366
|
+
return False
|
|
367
|
+
if parts.hostname == HOST:
|
|
368
|
+
return False
|
|
369
|
+
return not any(h in href for h in IGNORED_HOSTS)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def write_links_md(con, course_id: int, root: str) -> None:
|
|
373
|
+
"""每个目录一份 links.md,从数据库重写(外部链接没法下载,就汇总成清单)。"""
|
|
374
|
+
rows = con.execute("SELECT folder, section, title, url FROM links WHERE course_id=?"
|
|
375
|
+
" ORDER BY folder, section, rowid", (course_id,)).fetchall()
|
|
376
|
+
by_folder: dict[str, list] = {}
|
|
377
|
+
for r in rows:
|
|
378
|
+
by_folder.setdefault(r["folder"], []).append(r)
|
|
379
|
+
for folder, items in by_folder.items():
|
|
380
|
+
lines = [f"# {folder} 的链接", ""]
|
|
381
|
+
last = None
|
|
382
|
+
seen = set()
|
|
383
|
+
for r in items:
|
|
384
|
+
if r["url"] in seen:
|
|
385
|
+
continue
|
|
386
|
+
seen.add(r["url"])
|
|
387
|
+
if r["section"] != last and r["section"]:
|
|
388
|
+
lines += ["", f"## {r['section']}", ""]
|
|
389
|
+
last = r["section"]
|
|
390
|
+
lines.append(f"- [{r['title'] or r['url']}]({r['url']})")
|
|
391
|
+
path = FILES_DIR / root / folder / "links.md"
|
|
392
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
393
|
+
path.write_text("\n".join(lines).replace("\n\n\n", "\n\n") + "\n", encoding="utf-8")
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""从课件里抽文字:PDF(按页)、docx/pptx(按段/页)。
|
|
2
|
+
|
|
3
|
+
PDF 默认用 pypdf(纯 Python,随包安装);朋友的电脑上多半没有 pdftotext,Windows 更没有。
|
|
4
|
+
装了 poppler 的机器(比如服务器)可以 `monash config pdf_extractor pdftotext`,排版更准。
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import html
|
|
9
|
+
import logging
|
|
10
|
+
import re
|
|
11
|
+
import shutil
|
|
12
|
+
import subprocess
|
|
13
|
+
import zipfile
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
logging.getLogger("pypdf").setLevel(logging.ERROR) # 课件 PDF 常有小毛病,pypdf 会刷一堆警告
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def pdf_pages(path: Path) -> list[str]:
|
|
20
|
+
"""每页一段文字;坏文件、加密文件返回空列表。"""
|
|
21
|
+
from .paths import load_settings
|
|
22
|
+
if load_settings().get("pdf_extractor") == "pdftotext" and shutil.which("pdftotext"):
|
|
23
|
+
pages = _pdftotext(path)
|
|
24
|
+
if pages is not None:
|
|
25
|
+
return pages
|
|
26
|
+
try:
|
|
27
|
+
from pypdf import PdfReader
|
|
28
|
+
reader = PdfReader(str(path))
|
|
29
|
+
if reader.is_encrypted:
|
|
30
|
+
try:
|
|
31
|
+
reader.decrypt("")
|
|
32
|
+
except Exception: # noqa: BLE001
|
|
33
|
+
return []
|
|
34
|
+
out = []
|
|
35
|
+
for page in reader.pages:
|
|
36
|
+
try:
|
|
37
|
+
out.append(page.extract_text() or "")
|
|
38
|
+
except Exception: # noqa: BLE001 一页坏了不影响别的页
|
|
39
|
+
out.append("")
|
|
40
|
+
return out
|
|
41
|
+
except Exception: # noqa: BLE001
|
|
42
|
+
return []
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _pdftotext(path: Path) -> list[str] | None:
|
|
46
|
+
try:
|
|
47
|
+
r = subprocess.run(["pdftotext", "-layout", str(path), "-"], capture_output=True, text=True,
|
|
48
|
+
timeout=120, errors="replace")
|
|
49
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
50
|
+
return None
|
|
51
|
+
if r.returncode != 0:
|
|
52
|
+
return None
|
|
53
|
+
pages = r.stdout.split("\f")
|
|
54
|
+
if pages and not pages[-1].strip():
|
|
55
|
+
pages.pop() # 最后一页后面也有一个换页符
|
|
56
|
+
return pages
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _xml_text(xml: str, para_end: str, sep: str) -> str:
|
|
60
|
+
"""去掉标签、还原 & < 这些转义。
|
|
61
|
+
|
|
62
|
+
一个词常被拆成好几个 run(<a:t>Hel</a:t><a:t>lo</a:t>),所以标签之间不加空格,
|
|
63
|
+
只在段落结尾加 sep,不然搜 "Hello" 会搜不到。"""
|
|
64
|
+
xml = re.sub(r"<[aw]:(?:br|tab)\b[^>]*/>", " ", re.sub(para_end, sep, xml)) # 换行、制表符当空格
|
|
65
|
+
return html.unescape(re.sub(r"<[^>]+>", "", xml))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
OFFICE_XML_MAX = 50 * 1024 * 1024 # 单个 slide/document.xml 解压后最多读这么多,防解压炸弹
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _read_member(z: zipfile.ZipFile, name: str) -> str:
|
|
72
|
+
with z.open(name) as f:
|
|
73
|
+
data = f.read(OFFICE_XML_MAX + 1)
|
|
74
|
+
if len(data) > OFFICE_XML_MAX:
|
|
75
|
+
raise ValueError(f"{name} 解压后太大,不读")
|
|
76
|
+
return data.decode(errors="replace")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def office_parts(path: Path) -> list[tuple[str, str]]:
|
|
80
|
+
"""pptx → [("slide N", 文字)];docx → [("", 全文,段落间空行)]。"""
|
|
81
|
+
with zipfile.ZipFile(path) as z:
|
|
82
|
+
if path.suffix.lower() == ".pptx":
|
|
83
|
+
nums = sorted(int(m.group(1)) for n in z.namelist()
|
|
84
|
+
if (m := re.match(r"ppt/slides/slide(\d+)\.xml$", n)))
|
|
85
|
+
return [(f"slide {i}", " ".join(_xml_text(_read_member(z, f"ppt/slides/slide{i}.xml"),
|
|
86
|
+
r"</a:p>", " ").split()))
|
|
87
|
+
for i in nums]
|
|
88
|
+
xml = _read_member(z, "word/document.xml")
|
|
89
|
+
return [("", _xml_text(xml, r"</w:p>", "\n\n"))]
|
monash_study_kit/todo.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""本周待办:把 Moodle 和 Ed 两边“要我做点什么”的东西合成一张清单。
|
|
2
|
+
|
|
3
|
+
monash todo [--days 7]
|
|
4
|
+
|
|
5
|
+
- Moodle:接下来 N 天的截止/日历事件(会话失效时用 iCal 订阅链接)、已过截止但没交也没分的作业;
|
|
6
|
+
- 最近 N 天新同步下来的课件(读本地库,不发请求);
|
|
7
|
+
- Ed:还没做完的 lesson(只到你目前进度的下一周)、最近 N 天的公告、有未读新回复的帖子。
|
|
8
|
+
|
|
9
|
+
Ed 的部分读本地的 ed.db(只读打开),不发请求;库由 sync 维护。
|
|
10
|
+
Moodle 某一块拿不到(比如会话失效后作业汇总要登录)就在 notes 里说一声,其余照常给。
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
import sqlite3
|
|
16
|
+
import time
|
|
17
|
+
import urllib.error
|
|
18
|
+
from datetime import datetime, timedelta, timezone
|
|
19
|
+
|
|
20
|
+
from . import features as F
|
|
21
|
+
from .edlib import DB_PATH as ED_DB
|
|
22
|
+
from .moodlelib import MoodleAuthError, MoodleClient, MoodleError, db_connect
|
|
23
|
+
from .syncer import course_code
|
|
24
|
+
|
|
25
|
+
# 没网、Moodle 抽风:这些错误只影响 Moodle 那一块,Ed 部分读的是本地库,照常给
|
|
26
|
+
NETWORK_ERRORS = (MoodleError, urllib.error.URLError, TimeoutError, ConnectionError)
|
|
27
|
+
|
|
28
|
+
WEEK_RE = re.compile(r"\bW(?:eek)?\s*(\d{1,2})\b", re.I)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _week(*texts: str | None) -> int | None:
|
|
32
|
+
for t in texts:
|
|
33
|
+
m = WEEK_RE.search(t or "")
|
|
34
|
+
if m:
|
|
35
|
+
return int(m.group(1))
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _code(s: str | None) -> str:
|
|
40
|
+
m = re.search(r"[A-Z]{3}\d{4}", s or "")
|
|
41
|
+
return m.group(0) if m else (s or "").strip()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _ed_conn() -> sqlite3.Connection | None:
|
|
45
|
+
db = ED_DB
|
|
46
|
+
if not db.exists():
|
|
47
|
+
return None
|
|
48
|
+
con = sqlite3.connect(f"file:{db}?mode=ro", uri=True, timeout=10)
|
|
49
|
+
con.row_factory = sqlite3.Row
|
|
50
|
+
return con
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def ed_lessons_todo(con: sqlite3.Connection) -> list[dict]:
|
|
54
|
+
"""没做完的 lesson,只到“目前进度的下一周”。
|
|
55
|
+
|
|
56
|
+
Ed 的 lesson 没有截止日期,也不知道现在是第几周(Monash 有期中假,按开学日推算会差一周),
|
|
57
|
+
所以用你自己的进度判断:每门课里做过(attempted/completed)的最大周次 + 1。
|
|
58
|
+
这样落下的前几周会列出来,还没到的后几周不会刷屏。
|
|
59
|
+
"""
|
|
60
|
+
rows = con.execute("""SELECT l.id, l.course_id, l.title, l.status, m.name AS module, c.code
|
|
61
|
+
FROM lessons l LEFT JOIN modules m ON m.id = l.module_id
|
|
62
|
+
JOIN courses c ON c.id = l.course_id
|
|
63
|
+
WHERE c.tracked = 1 AND COALESCE(l.is_hidden, 0) = 0""").fetchall()
|
|
64
|
+
by_course: dict[int, list] = {}
|
|
65
|
+
for r in rows:
|
|
66
|
+
by_course.setdefault(r["course_id"], []).append(r)
|
|
67
|
+
out = []
|
|
68
|
+
for cid, ls in by_course.items():
|
|
69
|
+
done_weeks = [w for r in ls if r["status"] in ("attempted", "completed")
|
|
70
|
+
and (w := _week(r["title"], r["module"])) is not None]
|
|
71
|
+
upto = (max(done_weeks) + 1) if done_weeks else 1
|
|
72
|
+
for r in ls:
|
|
73
|
+
w = _week(r["title"], r["module"])
|
|
74
|
+
if r["status"] == "completed" or w is None or w > upto:
|
|
75
|
+
continue
|
|
76
|
+
out.append({"course": _code(r["code"]), "week": w, "title": r["title"], "status": r["status"],
|
|
77
|
+
"url": f"https://edstem.org/au/courses/{cid}/lessons/{r['id']}"})
|
|
78
|
+
return sorted(out, key=lambda x: (x["course"], x["week"], x["title"]))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def ed_announcements(con: sqlite3.Connection, days: int) -> list[dict]:
|
|
82
|
+
since = (datetime.now(timezone.utc) - timedelta(days=days)).isoformat()
|
|
83
|
+
rows = con.execute("""SELECT t.number, t.title, t.created_at, t.url, c.code FROM threads t
|
|
84
|
+
JOIN courses c ON c.id = t.course_id
|
|
85
|
+
WHERE c.tracked = 1 AND t.type = 'announcement'
|
|
86
|
+
AND julianday(t.created_at) >= julianday(?)
|
|
87
|
+
ORDER BY julianday(t.created_at) DESC""", (since,)).fetchall()
|
|
88
|
+
return [{"course": _code(r["code"]), "source": "ed", "ref": f"{_code(r['code'])}#{r['number']}",
|
|
89
|
+
"title": r["title"], "time": r["created_at"], "url": r["url"]} for r in rows]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def ed_unread_replies(con: sqlite3.Connection) -> list[dict]:
|
|
93
|
+
try:
|
|
94
|
+
rows = con.execute("""SELECT t.number, t.title, t.url, c.code, u.new_reply_count, u.is_mine, u.is_watched
|
|
95
|
+
FROM thread_state u JOIN threads t ON t.id = u.thread_id
|
|
96
|
+
JOIN courses c ON c.id = t.course_id
|
|
97
|
+
WHERE c.tracked = 1 AND u.new_reply_count > 0
|
|
98
|
+
ORDER BY (u.is_mine OR u.is_watched) DESC, julianday(t.updated_at) DESC""").fetchall()
|
|
99
|
+
except sqlite3.OperationalError: # 还没同步过 Ed
|
|
100
|
+
return []
|
|
101
|
+
return [{"ref": f"{_code(r['code'])}#{r['number']}", "title": r["title"], "new": r["new_reply_count"],
|
|
102
|
+
"mine_or_watched": bool(r["is_mine"] or r["is_watched"]), "url": r["url"]} for r in rows]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _ts(iso: str | None) -> float:
|
|
106
|
+
try:
|
|
107
|
+
return datetime.fromisoformat(iso).timestamp() if iso else 0.0
|
|
108
|
+
except ValueError:
|
|
109
|
+
return 0.0
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def new_files(courses: list[dict], days: int, limit: int = 30) -> list[dict]:
|
|
113
|
+
"""最近 N 天新下载或者被老师换过的课件。synced_at 在重新下载时也会更新,所以换过的也算。"""
|
|
114
|
+
ids = [int(c["id"]) for c in courses]
|
|
115
|
+
if not ids:
|
|
116
|
+
return []
|
|
117
|
+
since = (datetime.now(timezone.utc) - timedelta(days=days)).isoformat(timespec="seconds")
|
|
118
|
+
con = db_connect()
|
|
119
|
+
try:
|
|
120
|
+
rows = con.execute(f"""SELECT f.path, f.title, f.synced_at, c.code FROM files f
|
|
121
|
+
JOIN courses c ON c.id = f.course_id
|
|
122
|
+
WHERE f.course_id IN ({",".join("?" * len(ids))}) AND f.removed_at IS NULL
|
|
123
|
+
AND f.synced_at >= ? AND f.path NOT LIKE '%.transcript.md'
|
|
124
|
+
ORDER BY f.synced_at DESC LIMIT ?""", (*ids, since, limit)).fetchall()
|
|
125
|
+
finally:
|
|
126
|
+
con.close()
|
|
127
|
+
return [{"course": _code(r["code"]), "path": r["path"], "activity": r["title"], "synced_at": r["synced_at"]}
|
|
128
|
+
for r in rows]
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def build(client: MoodleClient | None, courses: list[dict], days: int = 7) -> dict:
|
|
132
|
+
notes: list[str] = []
|
|
133
|
+
out: dict = {"days": days, "generated_at": datetime.now(timezone.utc).isoformat(timespec="minutes")}
|
|
134
|
+
|
|
135
|
+
try:
|
|
136
|
+
out["due"] = [d for d in F.due(client, days, include_overdue_days=0) if not d["overdue"]]
|
|
137
|
+
if out["due"] and out["due"][0]["source"] == "ical":
|
|
138
|
+
notes.append("Moodle 会话失效:截止日期来自日历订阅链接,没有提交状态")
|
|
139
|
+
except MoodleAuthError:
|
|
140
|
+
out["due"] = []
|
|
141
|
+
notes.append("Moodle 没登录(或登录已过期),也没有日历订阅链接:截止日期缺失")
|
|
142
|
+
except NETWORK_ERRORS as e:
|
|
143
|
+
out["due"] = []
|
|
144
|
+
notes.append(f"连不上 Moodle({e}):截止日期缺失,稍后再试")
|
|
145
|
+
|
|
146
|
+
missing, news = [], []
|
|
147
|
+
now = int(time.time())
|
|
148
|
+
for c in courses:
|
|
149
|
+
try:
|
|
150
|
+
missing += [a for a in F.assignments(client, c) if a["missing"]]
|
|
151
|
+
news += [{"course": course_code(c), "source": "moodle", "ref": None, "title": n["title"],
|
|
152
|
+
"time": n["time"], "url": n.get("url")}
|
|
153
|
+
for n in F.news(client, c, 3) if (n.get("time_ts") or 0) >= now - days * 86400]
|
|
154
|
+
except MoodleAuthError:
|
|
155
|
+
notes.append("Moodle 会话失效:作业汇总和 Moodle 公告缺失")
|
|
156
|
+
break
|
|
157
|
+
except NETWORK_ERRORS as e:
|
|
158
|
+
notes.append(f"连不上 Moodle({e}):作业汇总和 Moodle 公告缺失")
|
|
159
|
+
break
|
|
160
|
+
out["possibly_missing"] = missing
|
|
161
|
+
out["new_files"] = new_files(courses, days)
|
|
162
|
+
|
|
163
|
+
con = _ed_conn()
|
|
164
|
+
if con is None:
|
|
165
|
+
notes.append("还没同步过 Ed(没设置令牌,或者第一次同步还没跑完):Ed 部分缺失")
|
|
166
|
+
out.update(ed_lessons=[], ed_unread_replies=[])
|
|
167
|
+
else:
|
|
168
|
+
with con:
|
|
169
|
+
try:
|
|
170
|
+
out["ed_lessons"] = ed_lessons_todo(con)
|
|
171
|
+
except sqlite3.OperationalError:
|
|
172
|
+
out["ed_lessons"] = []
|
|
173
|
+
news += ed_announcements(con, days)
|
|
174
|
+
out["ed_unread_replies"] = ed_unread_replies(con)
|
|
175
|
+
con.close()
|
|
176
|
+
out["announcements"] = sorted(news, key=lambda n: _ts(n["time"]), reverse=True)
|
|
177
|
+
out["notes"] = notes
|
|
178
|
+
return out
|