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,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
|