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.
@@ -0,0 +1,591 @@
1
+ """只读功能:截止日期、成绩、公告、论坛、通知、查找、单个下载。
2
+
3
+ 每个函数返回普通的 dict / list,CLI、网页和 MCP 共用同一份逻辑。
4
+
5
+ 哪些走接口、哪些解析页面,是在 Monash 上逐个试出来的(2026-09):
6
+ 接口能用:core_calendar_get_action_events_by_timesort、core_calendar_get_calendar_upcoming_view、
7
+ mod_forum_get_discussion_posts、
8
+ message_popup_get_popup_notifications、core_message_get_unread_conversation_counts
9
+ 没开放("Web service is not available"):gradereport_user_*、mod_forum_get_forum_discussions、
10
+ mod_assign_get_submission_status、core_search_get_results:这些都解析页面
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import html
15
+ import re
16
+ import time
17
+ import urllib.parse
18
+ import urllib.request
19
+ from datetime import datetime, timedelta, timezone
20
+ from pathlib import Path
21
+
22
+ from . import htmldom, moodlelib
23
+ from .moodlelib import BASE_URL, HOST, MoodleAuthError, MoodleClient, filename_from, is_attachment
24
+ from .paths import SECRETS_DIR, TZ_OFFSET, write_secret
25
+ from .syncer import clean, course_code, safe_name
26
+
27
+ MOODLE_URL_RE = re.compile(r"/mod/(\w+)/view\.php\?(?:.*&)?id=(\d+)")
28
+
29
+
30
+ # ---------------------------------------------------------------- 课程引用
31
+
32
+ def find_course(courses: list[dict], ref: str) -> dict:
33
+ """课号 / id / 课程 URL / 名字片段 → 课程。同课号多期取最新。"""
34
+ ref = ref.strip()
35
+ m = re.search(r"course/view\.php\?id=(\d+)", ref)
36
+ if m:
37
+ ref = m.group(1)
38
+ by_start = sorted(courses, key=lambda c: -(c.get("startdate") or 0))
39
+ hits = [c for c in by_start if str(c["id"]) == ref] \
40
+ or [c for c in by_start if (course_code(c) or "") == ref.upper()] \
41
+ or [c for c in by_start if ref.lower() in clean(c.get("fullname", "")).lower()]
42
+ if not hits:
43
+ raise LookupError(f"找不到课程 {ref}")
44
+ return hits[0]
45
+
46
+
47
+ def _ts(ts: int | None) -> str | None:
48
+ return datetime.fromtimestamp(ts, timezone.utc).isoformat(timespec="minutes") if ts else None
49
+
50
+
51
+ # ---------------------------------------------------------------- 截止日期
52
+
53
+ KIND_RE = re.compile(r"\((Due date|Cut-off date|Opens|Closes)\)\s*$|\b(opens|closes|is due)\s*$", re.I)
54
+ # upcoming view 的 eventtype → 和 action events 名字后缀一致的 kind
55
+ EVENTTYPE_KIND = {"due": "due", "open": "opens", "close": "closes", "gradingdue": "grading due",
56
+ "expectcompletionon": "expected"}
57
+
58
+
59
+ def _due_item(e: dict, now: int) -> dict:
60
+ c = e.get("course") or {}
61
+ action = e.get("action") or {}
62
+ name = clean(e.get("name", ""))
63
+ kind = KIND_RE.search(name)
64
+ ts = e.get("timesort") or e.get("timestart")
65
+ return {
66
+ "id": e.get("id"),
67
+ "course": course_code(c) or clean(c.get("shortname")),
68
+ "course_id": c.get("id"),
69
+ "name": re.sub(r"\s+is due$", "", name),
70
+ "kind": (kind.group(1) or kind.group(2)).lower() if kind
71
+ else EVENTTYPE_KIND.get(e.get("eventtype"), e.get("eventtype") or "event"),
72
+ "activity": clean(e.get("activityname")),
73
+ "module": e.get("modulename"),
74
+ "due": _ts(ts),
75
+ "due_ts": ts,
76
+ "overdue": bool(e.get("overdue")) or (ts or 0) < now,
77
+ "action": action.get("name"),
78
+ "actionable": bool(action.get("actionable")),
79
+ "url": e.get("url") or e.get("viewurl"),
80
+ "source": "api",
81
+ }
82
+
83
+
84
+ def due(client: MoodleClient | None, days: int = 14, course: dict | None = None,
85
+ include_overdue_days: int = 7) -> list[dict]:
86
+ """截止日期和日历事件。
87
+
88
+ action events 只含“要你做点什么”的事件(交作业、测验关闭),测验开放、课程/个人日历事件
89
+ 不在里面,所以再并上 upcoming view(Moodle 首页“即将发生”那块,范围是站点默认的几周)。
90
+ 会话失效时退回 iCal 订阅链接(不需要会话,见 calendar_url);没存链接就照常抛 MoodleAuthError。
91
+ """
92
+ now = int(time.time())
93
+ lo, hi = now - include_overdue_days * 86400, now + days * 86400
94
+ try:
95
+ if client is None:
96
+ raise MoodleAuthError("还没登录 Moodle")
97
+ events = client.call("core_calendar_get_action_events_by_timesort",
98
+ {"timesortfrom": lo, "timesortto": hi, "limitnum": 50})["events"]
99
+ try:
100
+ events += client.call("core_calendar_get_calendar_upcoming_view",
101
+ {"courseid": 1, "categoryid": 0})["events"]
102
+ except MoodleAuthError:
103
+ raise
104
+ except Exception:
105
+ pass # 补充来源,拿不到就算了
106
+ items = [_due_item(e, now) for e in events]
107
+ except MoodleAuthError:
108
+ url = load_calendar_url()
109
+ if not url:
110
+ raise
111
+ items = ical_items(fetch_ical(url), now)
112
+ out, seen = [], set()
113
+ for i in items:
114
+ key = i["id"] if i["id"] is not None else (i["name"], i["due_ts"])
115
+ if key in seen or not (lo <= (i["due_ts"] or 0) <= hi):
116
+ continue
117
+ if course and not ((i["course_id"] is not None and i["course_id"] == course["id"])
118
+ or i["course"] == course_code(course)):
119
+ continue
120
+ seen.add(key)
121
+ out.append(i)
122
+ return sorted(out, key=lambda x: x["due_ts"] or 0)
123
+
124
+
125
+ # ---------------------------------------------------------------- 日历订阅(iCal)
126
+ #
127
+ # calendar/export.php 的 “Get calendar URL” 给一个带 authtoken 的 export_execute.php 链接。
128
+ # 这个链接不需要登录会话,会话过期了照样能拿到截止日期,也能直接订阅进苹果/Google 日历。
129
+ # authtoken 由用户 id 和账号密码的盐算出来,等同于“只读日历密码”:存 600 权限,别打印全文。
130
+
131
+ CALENDAR_URL_FILE = SECRETS_DIR / "calendar-url"
132
+
133
+
134
+ def load_calendar_url() -> str | None:
135
+ try:
136
+ url = CALENDAR_URL_FILE.read_text(encoding="utf-8").strip()
137
+ except OSError:
138
+ return None
139
+ return url or None
140
+
141
+
142
+ def save_calendar_url(url: str) -> None:
143
+ write_secret(CALENDAR_URL_FILE, url + "\n")
144
+
145
+
146
+ def mask_calendar_url(url: str) -> str:
147
+ return re.sub(r"(authtoken=)\w+", r"\1…", url)
148
+
149
+
150
+ def calendar_url(client: MoodleClient, refresh: bool = False) -> str:
151
+ """拿(必要时生成并保存)iCal 订阅链接:所有课程、最近 + 接下来的事件。"""
152
+ if not refresh and (url := load_calendar_url()):
153
+ return url
154
+ r = client.post("/calendar/export.php", [
155
+ ("sesskey", client.sesskey()), ("_qf__core_calendar_export_form", "1"),
156
+ ("events[exportevents]", "all"), ("period[timeperiod]", "recentupcoming"),
157
+ ("generateurl", "Get calendar URL")])
158
+ m = re.search(r"https?://[^\s\"'<>]+/calendar/export_execute\.php\?[^\s\"'<>]+",
159
+ html.unescape(r.text()))
160
+ if not m:
161
+ raise RuntimeError("导出页没有给出日历链接(页面结构变了?)")
162
+ save_calendar_url(m.group(0))
163
+ return m.group(0)
164
+
165
+
166
+ def fetch_ical(url: str, timeout: int = 30) -> str:
167
+ """不带 cookie 取 iCal。"""
168
+ req = urllib.request.Request(url, headers={"User-Agent": moodlelib.UA})
169
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
170
+ return resp.read().decode("utf-8", "replace")
171
+
172
+
173
+ def parse_ical(text: str) -> list[dict]:
174
+ """最小的 VEVENT 解析:折行、转义、UTC 时间。只取我们用得到的字段。"""
175
+ lines = re.sub(r"\r?\n[ \t]", "", text).splitlines()
176
+ events, cur = [], None
177
+ for line in lines:
178
+ if line == "BEGIN:VEVENT":
179
+ cur = {}
180
+ elif line == "END:VEVENT" and cur is not None:
181
+ events.append(cur)
182
+ cur = None
183
+ elif cur is not None and ":" in line:
184
+ key, val = line.split(":", 1)
185
+ key = key.split(";", 1)[0].upper()
186
+ cur[key] = re.sub(r"\\([\\,;nN])", lambda m: "\n" if m.group(1) in "nN" else m.group(1), val)
187
+ return events
188
+
189
+
190
+ def _ical_ts(v: str | None) -> int | None:
191
+ if not v:
192
+ return None
193
+ for fmt in ("%Y%m%dT%H%M%SZ", "%Y%m%dT%H%M%S", "%Y%m%d"):
194
+ try:
195
+ return int(datetime.strptime(v, fmt).replace(tzinfo=timezone.utc).timestamp())
196
+ except ValueError:
197
+ continue
198
+ return None
199
+
200
+
201
+ # 日历标题 = 活动名 + Monash 追加的排课/系统信息 + 类型后缀,比如
202
+ # "Week 8 Applied Session Submission - Allocate+ FIT2102 Tutorial 09 MA_... Wed 14:00 (Due date)"
203
+ # "Assignment 2 - CALLISTA: FIT2109 MALAYSIA ON-CAMPUS S2 2026 (Due date)"
204
+ # 接口版有单独的 activityname,iCal 没有,只能从标题里剥。
205
+ ICAL_SUFFIX_RE = re.compile(r"\s+-\s+(Allocate\+|CALLISTA:).*$", re.I)
206
+
207
+
208
+ def ical_activity(summary: str) -> str:
209
+ name = KIND_RE.sub("", summary).strip()
210
+ return ICAL_SUFFIX_RE.sub("", name).strip() or summary
211
+
212
+
213
+ def ical_items(text: str, now: int | None = None) -> list[dict]:
214
+ now = now or int(time.time())
215
+ out = []
216
+ for e in parse_ical(text):
217
+ name = clean(e.get("SUMMARY", ""))
218
+ kind = KIND_RE.search(name)
219
+ ts = _ical_ts(e.get("DTSTART"))
220
+ cat = e.get("CATEGORIES", "")
221
+ out.append({
222
+ "id": int(e["UID"].split("@")[0]) if e.get("UID", "").split("@")[0].isdigit() else e.get("UID"),
223
+ "course": (m.group(0) if (m := re.search(r"[A-Z]{3}\d{4}", cat)) else cat) or None,
224
+ "course_id": None,
225
+ "name": re.sub(r"\s+is due$", "", name),
226
+ "kind": (kind.group(1) or kind.group(2)).lower() if kind else "event",
227
+ "activity": ical_activity(name), "module": None,
228
+ "due": _ts(ts), "due_ts": ts, "overdue": (ts or 0) < now,
229
+ "action": None, "actionable": False, "url": None,
230
+ "source": "ical",
231
+ })
232
+ return out
233
+
234
+
235
+ # ---------------------------------------------------------------- 成绩
236
+
237
+ def grades_overview(client: MoodleClient) -> list[dict]:
238
+ doc = htmldom.parse(client.html("/grade/report/overview/index.php"))
239
+ out = []
240
+ for table in doc.find_all("table", id=lambda v: v.startswith("overview-grade")):
241
+ for tr in table.find_all("tr"):
242
+ tds = tr.find_all("td")
243
+ if len(tds) >= 2:
244
+ a = tds[0].find("a")
245
+ cid = re.search(r"[?&]id=(\d+)", a.get("href", "")) if a else None
246
+ out.append({"course": tds[0].text(), "course_id": int(cid.group(1)) if cid else None,
247
+ "grade": tds[1].text()})
248
+ return out
249
+
250
+
251
+ def grades(client: MoodleClient, course_id: int, graded_only: bool = False) -> list[dict]:
252
+ doc = htmldom.parse(client.html(f"/grade/report/user/index.php?id={course_id}"))
253
+ table = doc.find("table", cls="user-grade")
254
+ if table is None:
255
+ return []
256
+ out = []
257
+ for tr in table.find_all("tr"):
258
+ name_cell = tr.find("th", cls="column-itemname")
259
+ grade = tr.find("td", cls="column-grade")
260
+ if not (name_cell and grade):
261
+ continue
262
+ title = name_cell.find(cls="rowtitle") or name_cell
263
+ kind = name_cell.find("span", cls="text-uppercase")
264
+ link = title.find("a")
265
+ cell = lambda c: (tr.find("td", cls=c).text() if tr.find("td", cls=c) else "") # noqa: E731
266
+ fb = tr.find("td", cls="column-feedback")
267
+ item = {
268
+ "item": title.text(),
269
+ "type": kind.text() if kind else "",
270
+ "grade": grade.text(),
271
+ "range": cell("column-range"),
272
+ "percentage": cell("column-percentage"),
273
+ "weight": cell("column-weight"),
274
+ "feedback": fb.text(sep="\n") if fb else "",
275
+ "feedback_files": [a.get("href") for a in (fb.find_all("a") if fb else []) if is_attachment(a.get("href", ""))],
276
+ "url": link.get("href") if link else None,
277
+ }
278
+ if graded_only and item["grade"] in ("", "-"):
279
+ continue
280
+ out.append(item)
281
+ return out
282
+
283
+
284
+ # ---------------------------------------------------------------- 作业汇总
285
+
286
+ # 页面上的时间按 Moodle 账号的时区显示。马来西亚校区的账号是 UTC+8(和日历接口对得上:11:55 PM ↔ 15:55Z);
287
+ # 澳洲校区的同学 `monash config tz_offset 10`(夏令时 11)。
288
+ PAGE_TZ = timezone(timedelta(hours=TZ_OFFSET))
289
+ NOT_SUBMITTED = {"no submission", "not submitted", "draft (not submitted)", "no attempt", "new"}
290
+
291
+
292
+ def parse_page_time(text: str) -> int | None:
293
+ """"Sunday, 6 September 2026, 11:55 PM" → 时间戳;"-" 或认不出来 → None。"""
294
+ text = clean(text)
295
+ for fmt in ("%A, %d %B %Y, %I:%M %p", "%d %B %Y, %I:%M %p"):
296
+ try:
297
+ return int(datetime.strptime(text, fmt).replace(tzinfo=PAGE_TZ).timestamp())
298
+ except ValueError:
299
+ continue
300
+ return None
301
+
302
+
303
+ def assignments(client: MoodleClient, course: dict, now: int | None = None) -> list[dict]:
304
+ """/mod/assign/index.php:一门课所有作业的截止时间、提交状态、成绩,一页拿全。
305
+
306
+ missing = 已过截止、没交、也没分。只是“可能漏交”:面试、现场展示这类作业本来就不用在线交,
307
+ Monash 也常把线下测验挂成作业(No submission 但有分,那种不算)。
308
+ """
309
+ now = now or int(time.time())
310
+ doc = htmldom.parse(client.html(f"/mod/assign/index.php?id={course['id']}"))
311
+ table = doc.find("table", cls="generaltable")
312
+ if table is None:
313
+ return []
314
+ out, section = [], ""
315
+ for tr in table.find_all("tr"):
316
+ tds = tr.find_all("td")
317
+ if len(tds) < 5:
318
+ continue
319
+ section = tds[0].text() or section
320
+ link = tds[1].find("a")
321
+ due_ts = parse_page_time(tds[2].text())
322
+ status, grade = tds[3].text(), tds[4].text()
323
+ graded = grade not in ("", "-")
324
+ submitted = status.lower() not in NOT_SUBMITTED
325
+ m = re.search(r"[?&]id=(\d+)", link.get("href", "")) if link else None
326
+ out.append({
327
+ "course": course_code(course) or clean(course.get("shortname")),
328
+ "course_id": course["id"],
329
+ "section": section,
330
+ "name": tds[1].text(),
331
+ "cmid": int(m.group(1)) if m else None,
332
+ "due": _ts(due_ts), "due_ts": due_ts,
333
+ "status": status, "submitted": submitted,
334
+ "grade": grade if graded else None,
335
+ "missing": bool(due_ts and due_ts < now and not submitted and not graded),
336
+ "url": link.get("href") if link else None,
337
+ })
338
+ return out
339
+
340
+
341
+ # ---------------------------------------------------------------- 论坛 / 公告
342
+
343
+ def forums(client: MoodleClient, course_id: int, state: dict | None = None) -> list[dict]:
344
+ state = state or client.course_state(course_id)
345
+ return [{"id": int(cm["id"]), "name": clean(cm["name"]), "url": cm.get("url"),
346
+ "section": cm.get("sectionnumber")}
347
+ for cm in state["cm"] if cm.get("module") == "forum" and cm.get("uservisible", True)]
348
+
349
+
350
+ def discussions(client: MoodleClient, forum_cmid: int, limit: int = 20) -> list[dict]:
351
+ doc = htmldom.parse(client.html(f"/mod/forum/view.php?id={forum_cmid}"))
352
+ out = []
353
+ for tr in doc.find_all("tr", data__region="discussion-list-item"):
354
+ a = tr.find("a", cls="w-100") or tr.find("a", href=lambda h: "discuss.php?d=" in h)
355
+ did = tr.get("data-discussionid") or ""
356
+ if not a or not did.isdigit(): # 置顶分隔行之类没有 id,跳过,别让整页公告都读不出来
357
+ continue
358
+ author = tr.find(cls="author-info") or (tr.find_all("td")[2] if len(tr.find_all("td")) > 2 else None)
359
+ replies = tr.find(cls="replies") or tr.find(data__region="replies")
360
+ times = [t.get("datetime") for t in tr.find_all("time") if t.get("datetime")]
361
+ out.append({"id": int(did), "title": a.text(), "url": a.get("href"),
362
+ "author": author.text() if author else "", "replies": replies.text() if replies else None,
363
+ "time": times[0] if times else None,
364
+ "pinned": "pinned" in tr.classes or tr.find(data__region="pinned-label", hidden=None) is not None})
365
+ if len(out) >= limit:
366
+ break
367
+ return out
368
+
369
+
370
+ def discussion_posts(client: MoodleClient, discussion_id: int) -> list[dict]:
371
+ data = client.call("mod_forum_get_discussion_posts",
372
+ {"discussionid": discussion_id, "sortby": "created", "sortdirection": "ASC"})
373
+ return [{"id": p["id"], "subject": clean(p["subject"]), "author": clean((p.get("author") or {}).get("fullname")),
374
+ "time": _ts(p.get("timecreated")), "time_ts": p.get("timecreated"),
375
+ "text": htmldom.parse(p.get("message") or "").text(sep="\n"),
376
+ "parent": p.get("parentid"),
377
+ "attachments": [{"name": a.get("filename"), "url": a.get("url")} for a in p.get("attachments") or []],
378
+ "url": f"{BASE_URL}/mod/forum/discuss.php?d={discussion_id}#p{p['id']}"}
379
+ for p in data.get("posts", [])]
380
+
381
+
382
+ def news(client: MoodleClient, course: dict, limit: int = 5, state: dict | None = None) -> list[dict]:
383
+ """课程公告:第 0 节里叫 Announcements 的论坛,每帖带上首帖正文。"""
384
+ fs = forums(client, course["id"], state)
385
+ ann = [f for f in fs if "announcement" in f["name"].lower()] or [f for f in fs if f["section"] == 0]
386
+ if not ann:
387
+ return []
388
+ out = []
389
+ for d in discussions(client, ann[0]["id"], limit):
390
+ posts = discussion_posts(client, d["id"])
391
+ first = posts[0] if posts else {}
392
+ out.append({**d, "course": course_code(course) or clean(course.get("shortname")),
393
+ "author": first.get("author") or d["author"], "time": first.get("time") or d["time"],
394
+ "time_ts": first.get("time_ts"), "text": first.get("text", ""),
395
+ "attachments": first.get("attachments", [])})
396
+ return sorted(out, key=lambda x: -(x.get("time_ts") or 0))
397
+
398
+
399
+ def forum_search(client: MoodleClient, query: str, course_id: int = 1, limit: int = 30) -> list[dict]:
400
+ """用 Moodle 自带的论坛搜索(course_id=1 表示全站)。"""
401
+ url = f"/mod/forum/search.php?id={course_id}&search={urllib.parse.quote(query)}&perpage={limit}"
402
+ doc = htmldom.parse(client.html(url))
403
+ out = []
404
+ for art in doc.find_all(data__region="post"):
405
+ links = [a.get("href") for a in art.find_all("a") if a.get("href")]
406
+ disc = next((h for h in links if "discuss.php?d=" in h and "#p" in h), None) \
407
+ or next((h for h in links if "discuss.php?d=" in h), None)
408
+ forum = next((a for a in art.find_all("a") if "/mod/forum/view.php" in a.get("href", "")), None)
409
+ header = art.find(cls="d-flex") or art
410
+ subject = art.find(data__region_content="forum-post-core-subject") or art.find(["h3", "h4"])
411
+ body = art.find(id=f"post-content-{art.get('data-post-id')}") or art.find(cls="post-content-container")
412
+ author = art.find("a", href=lambda h: "/user/view.php" in h)
413
+ t = art.find("time")
414
+ out.append({"id": int(art.get("data-post-id") or 0),
415
+ "forum": forum.text() if forum else "",
416
+ "subject": subject.text() if subject else header.text()[:80],
417
+ "author": author.text() if author else "",
418
+ "time": t.get("datetime") if t else None,
419
+ "text": body.text(sep="\n") if body else "",
420
+ "url": disc})
421
+ return out
422
+
423
+
424
+ # ---------------------------------------------------------------- 通知
425
+
426
+ def alerts(client: MoodleClient, limit: int = 20) -> dict:
427
+ client.time_remaining() # 顺便拿 userid
428
+ n = client.call("message_popup_get_popup_notifications",
429
+ {"useridto": client.userid, "limit": limit, "offset": 0, "newestfirst": True})
430
+ counts = client.call("core_message_get_unread_conversation_counts", {"userid": client.userid})
431
+ unread_msgs = sum((counts.get("types") or {}).values()) + (counts.get("favourites") or 0)
432
+ return {"unread_notifications": n.get("unreadcount", 0), "unread_messages": unread_msgs,
433
+ "notifications": [{"subject": clean(x.get("subject")), "text": clean(x.get("smallmessage")),
434
+ "time": _ts(x.get("timecreated")), "read": bool(x.get("read")),
435
+ "from": clean(x.get("userfromfullname")),
436
+ "url": x.get("contexturl")} for x in n.get("notifications", [])]}
437
+
438
+
439
+ # ---------------------------------------------------------------- 站内私信
440
+ #
441
+ # core_message_get_conversations / core_message_get_conversation_messages 在 Monash 上能用
442
+ # (同一个 AJAX 入口,网页右上角的消息抽屉就是用它们)。只读:这两个都不会把消息标成已读。
443
+
444
+ CONV_TYPES = {1: "private", 2: "group", 3: "self"}
445
+
446
+
447
+ def _msg(m: dict, names: dict) -> dict:
448
+ return {"from": names.get(m.get("useridfrom"), str(m.get("useridfrom"))),
449
+ "text": htmldom.parse(m.get("text") or "").text(sep="\n").strip(),
450
+ "time": _ts(m.get("timecreated")), "time_ts": m.get("timecreated")}
451
+
452
+
453
+ def conversations(client: MoodleClient, limit: int = 20) -> list[dict]:
454
+ client.time_remaining() # 顺便拿 userid
455
+ data = client.call("core_message_get_conversations",
456
+ {"userid": client.userid, "limitfrom": 0, "limitnum": limit})
457
+ out = []
458
+ for c in data.get("conversations", []):
459
+ names = {u["id"]: clean(u.get("fullname")) for u in c.get("members", [])}
460
+ others = [n for i, n in names.items() if i != client.userid]
461
+ msgs = c.get("messages") or []
462
+ out.append({
463
+ "id": c["id"],
464
+ "type": CONV_TYPES.get(c.get("type"), str(c.get("type"))),
465
+ "name": clean(c.get("name")) or ", ".join(others) or "(自己)",
466
+ "members": c.get("membercount"),
467
+ "unread": c.get("unreadcount") or (0 if c.get("isread", True) else 1),
468
+ "last": _msg(msgs[0], names | {client.userid: "我"}) if msgs else None,
469
+ })
470
+ return out
471
+
472
+
473
+ def conversation_messages(client: MoodleClient, conv_id: int, limit: int = 30) -> dict:
474
+ client.time_remaining()
475
+ data = client.call("core_message_get_conversation_messages",
476
+ {"currentuserid": client.userid, "convid": conv_id, "limitfrom": 0,
477
+ "limitnum": limit, "newest": True})
478
+ names = {u["id"]: clean(u.get("fullname")) for u in data.get("members", [])}
479
+ names[client.userid] = "我"
480
+ msgs = [_msg(m, names) for m in data.get("messages", [])]
481
+ return {"id": conv_id, "messages": sorted(msgs, key=lambda m: m["time_ts"] or 0)}
482
+
483
+
484
+ # ---------------------------------------------------------------- 查找
485
+
486
+ def _score(query: str, text: str) -> float:
487
+ q, t = query.lower().split(), text.lower()
488
+ if not q:
489
+ return 0
490
+ hits = sum(1 for w in q if w in t)
491
+ if hits < len(q):
492
+ # 数字要整词匹配:7 不该命中 17
493
+ return 0
494
+ bonus = 2 if query.lower() in t else 0
495
+ for w in q:
496
+ if w.isdigit() and not re.search(rf"(?<!\d){w}(?!\d)", t):
497
+ return 0
498
+ return hits + bonus - len(t) / 500
499
+
500
+
501
+ def find(client: MoodleClient, query: str, courses: list[dict], types: set[str] | None = None,
502
+ limit: int = 20) -> list[dict]:
503
+ """在课程的章节名、活动名里找。"Week 7 slides" 会同时匹配章节和活动。"""
504
+ out = []
505
+ for c in courses:
506
+ state = client.course_state(c["id"])
507
+ sections = {s["number"]: s for s in state["section"]}
508
+
509
+ def section_path(num):
510
+ names, seen = [], set()
511
+ while num is not None and num in sections and num not in seen:
512
+ seen.add(num)
513
+ s = sections[num]
514
+ if s["number"] != 0 and (s.get("cmlist") or not names):
515
+ names.append(clean(s["title"]))
516
+ num = s.get("parent")
517
+ return " / ".join(reversed(names))
518
+
519
+ code = course_code(c) or clean(c.get("shortname"))
520
+ if not types or "section" in types:
521
+ for s in state["section"]:
522
+ sc = _score(query, clean(s["title"]))
523
+ if sc:
524
+ out.append({"score": sc + 1.5, "type": "section", "course": code, "name": clean(s["title"]),
525
+ "where": section_path(s.get("parent")), "url": s.get("sectionurl")})
526
+ for cm in state["cm"]:
527
+ if types and cm.get("module") not in types and "activity" not in types:
528
+ continue
529
+ path = section_path(cm.get("sectionnumber"))
530
+ sc = _score(query, f"{clean(cm['name'])} {path}")
531
+ if sc and cm.get("url"):
532
+ out.append({"score": sc + (_score(query, clean(cm["name"])) > 0), "type": cm.get("module"),
533
+ "course": code, "name": clean(cm["name"]), "where": path,
534
+ "id": int(cm["id"]), "url": cm.get("url")})
535
+ out.sort(key=lambda x: -x["score"])
536
+ return out[:limit]
537
+
538
+
539
+ # ---------------------------------------------------------------- 单个下载
540
+
541
+ def resolve_ref(client: MoodleClient, ref: str, courses: list[dict] | None = None) -> str:
542
+ """活动 id / Moodle URL / "FIT2102 applied 7" → 一个可下载的本站 URL。"""
543
+ ref = ref.strip()
544
+ if ref.isdigit():
545
+ return f"{BASE_URL}/mod/resource/view.php?id={ref}"
546
+ if ref.startswith("http"):
547
+ if urllib.parse.urlsplit(ref).hostname != HOST:
548
+ raise ValueError("只接受 learning.monash.edu 的链接")
549
+ return ref
550
+ unit, _, rest = ref.partition(" ")
551
+ courses = courses or client.courses()
552
+ course = find_course(courses, unit)
553
+ hits = [h for h in find(client, rest or unit, [course], types={"resource", "folder", "url"}) if h.get("id")]
554
+ if not hits:
555
+ raise LookupError(f"{course_code(course)} 里没找到 {rest!r}")
556
+ return hits[0]["url"]
557
+
558
+
559
+ def get(client: MoodleClient, ref: str, to: Path, force: bool = False,
560
+ courses: list[dict] | None = None) -> list[dict]:
561
+ """下载一个资源(或文件夹里的全部文件)到 to 目录,返回收据。"""
562
+ url = resolve_ref(client, ref, courses)
563
+ to.mkdir(parents=True, exist_ok=True)
564
+ m = MOODLE_URL_RE.search(url)
565
+ targets: list[str]
566
+ if m and m.group(1) == "folder":
567
+ doc = htmldom.parse(client.html(url))
568
+ targets = [a.get("href") for a in doc.find_all("a") if "/mod_folder/content/" in a.get("href", "")]
569
+ elif m and m.group(1) in ("resource", "url"):
570
+ local, external, resp = client.resolve(url + ("&" if "?" in url else "?") + "redirect=1")
571
+ if external and "cloudfront" not in external and m.group(1) == "url":
572
+ return [{"url": external, "path": None, "note": "外部链接,没有可下载的文件"}]
573
+ targets = [local]
574
+ else:
575
+ targets = [url]
576
+ receipts = []
577
+ for t in dict.fromkeys(targets):
578
+ tmp = to / f".mdl-get-{abs(hash(t))}" # 下载时 client.download 自己会加 .part
579
+ resp = client.download(t, tmp)
580
+ name = safe_name(filename_from(resp, "download"))
581
+ dest = to / name
582
+ if dest.exists() and not force:
583
+ stem, dot, ext = name.rpartition(".")
584
+ n = 2
585
+ while dest.exists():
586
+ dest = to / (f"{stem} ({n}).{ext}" if dot else f"{name} ({n})")
587
+ n += 1
588
+ tmp.replace(dest)
589
+ receipts.append({"path": str(dest), "bytes": dest.stat().st_size,
590
+ "content_type": (resp.headers.get("Content-Type") or "").split(";")[0]})
591
+ return receipts