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,275 @@
|
|
|
1
|
+
"""monash doctor --deep:拿第二个来源核对工具给出的结果,确认解析没坏、同步没漏。
|
|
2
|
+
|
|
3
|
+
普通的 doctor 只看配置和登录;这里真去 Moodle 和 Ed 拉一遍,再和另一处对照:
|
|
4
|
+
截止日期 Moodle 接口 ↔ iCal 日历订阅
|
|
5
|
+
作业 作业汇总页(解析 HTML) ↔ 课程结构接口里的作业
|
|
6
|
+
课件 课程结构接口里的文件 ↔ 本地已经同步的文件
|
|
7
|
+
全文搜索 磁盘上的文件 ↔ 索引,再随机搜几个词看能不能搜回原文件
|
|
8
|
+
Ed 帖子 Ed 接口最新一页 ↔ 本地库
|
|
9
|
+
成绩 成绩总览页 ↔ 跟踪的课
|
|
10
|
+
|
|
11
|
+
只读:不下载、不改库、不自动选课。输出只有数量、课号、日期和活动编号,
|
|
12
|
+
不打印标题、文件名、帖子内容、成绩,可以直接发给帮忙的人。
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import random
|
|
17
|
+
import re
|
|
18
|
+
import time
|
|
19
|
+
from datetime import datetime, timezone
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from . import content_index as CI
|
|
23
|
+
from . import edlib, edsync, features as F, jobs
|
|
24
|
+
from .doctor import FAIL, OK, WARN, _check, scrub
|
|
25
|
+
from .moodlelib import FILES_DIR, MoodleAuthError, MoodleClient, db_connect
|
|
26
|
+
from .paths import load_settings
|
|
27
|
+
from .syncer import VIDEO_EXT, course_code
|
|
28
|
+
|
|
29
|
+
DAYS = 14
|
|
30
|
+
DUE_KINDS = {"due", "due date", "closes", "cut-off date"}
|
|
31
|
+
SAMPLE = 3 # 每类差异最多举几个例子
|
|
32
|
+
RESOLVE_MAX = 10 # 每门课最多为了分辨“录像没下”多问几次 Moodle
|
|
33
|
+
# 只挑完整的单词:网址里的一段(比如 Google Docs 链接里的 ID)在索引里是另一个词,搜不回来不算错
|
|
34
|
+
WORD_RE = re.compile(r"(?<!\w)[A-Za-z]{7,}(?!\w)")
|
|
35
|
+
URL_RE = re.compile(r"\S+://\S+|www\.\S+")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Context:
|
|
39
|
+
"""几项检查共用一个 Moodle 会话和课程列表,免得每项都重新拉。"""
|
|
40
|
+
|
|
41
|
+
def __init__(self, client: MoodleClient | None = None, con=None):
|
|
42
|
+
self._client, self._courses = client, None
|
|
43
|
+
self.con = con or db_connect()
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def client(self) -> MoodleClient:
|
|
47
|
+
if self._client is None:
|
|
48
|
+
self._client = MoodleClient()
|
|
49
|
+
return self._client
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def tracked(self) -> list[dict]:
|
|
53
|
+
if self._courses is None:
|
|
54
|
+
ids = {r["id"] for r in self.con.execute("SELECT id FROM courses WHERE tracked=1")}
|
|
55
|
+
self._courses = [c for c in self.client.courses() if c["id"] in ids]
|
|
56
|
+
return self._courses
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _day(ts: int | None) -> str:
|
|
60
|
+
return datetime.fromtimestamp(ts).strftime("%m-%d") if ts else "?"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _parse_time(value: str | None) -> datetime | None:
|
|
64
|
+
if not value:
|
|
65
|
+
return None
|
|
66
|
+
try:
|
|
67
|
+
t = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
68
|
+
except ValueError:
|
|
69
|
+
return None
|
|
70
|
+
return t if t.tzinfo else t.replace(tzinfo=timezone.utc)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _examples(items) -> str:
|
|
74
|
+
items = list(items)
|
|
75
|
+
more = f" 等 {len(items)} 项" if len(items) > SAMPLE else ""
|
|
76
|
+
return "、".join(items[:SAMPLE]) + more
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# ---------------------------------------------------------------- 各项核对
|
|
80
|
+
|
|
81
|
+
def deep_due(ctx: Context) -> dict:
|
|
82
|
+
name = "核对:截止日期"
|
|
83
|
+
url = F.load_calendar_url()
|
|
84
|
+
if not url:
|
|
85
|
+
return _check(name, WARN, "没有日历订阅链接,没法对照", "monash moodle calendar --refresh")
|
|
86
|
+
now = int(time.time())
|
|
87
|
+
codes = {course_code(c) for c in ctx.tracked}
|
|
88
|
+
api = {i["id"]: i for i in F.due(ctx.client, days=DAYS, include_overdue_days=0)
|
|
89
|
+
if i["kind"] in DUE_KINDS and i["due_ts"] >= now}
|
|
90
|
+
ical = {i["id"]: i for i in F.ical_items(F.fetch_ical(url), now)
|
|
91
|
+
if i["kind"] in DUE_KINDS and now <= (i["due_ts"] or 0) <= now + DAYS * 86400
|
|
92
|
+
and (not codes or i["course"] in codes)}
|
|
93
|
+
only_ical = [f"{ical[k]['course']} {_day(ical[k]['due_ts'])}" for k in ical if k not in api]
|
|
94
|
+
only_api = [f"{api[k]['course']} {_day(api[k]['due_ts'])}" for k in api if k not in ical]
|
|
95
|
+
detail = f"未来 {DAYS} 天:接口 {len(api)} 条,日历订阅 {len(ical)} 条"
|
|
96
|
+
if only_ical:
|
|
97
|
+
return _check(name, WARN, f"{detail};日历里有、工具里没有:{_examples(only_ical)}",
|
|
98
|
+
"把这段发给维护的人:截止日期的接口解析可能漏了")
|
|
99
|
+
if only_api:
|
|
100
|
+
detail += f";接口多出 {len(only_api)} 条(日历订阅只导出部分事件,正常)"
|
|
101
|
+
return _check(name, OK, detail + ",一致" if not only_api else detail)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def deep_assignments(ctx: Context) -> dict:
|
|
105
|
+
name = "核对:作业"
|
|
106
|
+
bad, total = [], 0
|
|
107
|
+
for c in ctx.tracked:
|
|
108
|
+
state = ctx.client.course_state(c["id"])
|
|
109
|
+
want = {int(cm["id"]) for cm in state["cm"] if cm.get("module") == "assign" and cm.get("uservisible", True)}
|
|
110
|
+
got = {a["cmid"] for a in F.assignments(ctx.client, c) if a["cmid"]}
|
|
111
|
+
total += len(want)
|
|
112
|
+
if want - got:
|
|
113
|
+
bad.append(f"{course_code(c)} 少了 {len(want - got)} 个(cmid {_examples(str(i) for i in sorted(want - got))})")
|
|
114
|
+
detail = f"{len(ctx.tracked)} 门课,课程结构里 {total} 个作业"
|
|
115
|
+
if bad:
|
|
116
|
+
return _check(name, WARN, f"{detail};作业汇总页没解析出来:{';'.join(bad)}",
|
|
117
|
+
"把这段发给维护的人:作业页的格式可能变了")
|
|
118
|
+
return _check(name, OK, detail + ",作业汇总页都解析到了")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def deep_files(ctx: Context) -> dict:
|
|
122
|
+
name = "核对:课件"
|
|
123
|
+
videos = bool(load_settings().get("download_videos"))
|
|
124
|
+
missing, stale, gone_local, expected, total = [], [], [], 0, 0
|
|
125
|
+
for c in ctx.tracked:
|
|
126
|
+
state = ctx.client.course_state(c["id"])
|
|
127
|
+
cms = {int(cm["id"]): cm for cm in state["cm"] if cm.get("uservisible", True)}
|
|
128
|
+
rows = ctx.con.execute("SELECT cmid, path FROM files WHERE course_id=? AND removed_at IS NULL",
|
|
129
|
+
(c["id"],)).fetchall()
|
|
130
|
+
have = {r["cmid"] for r in rows}
|
|
131
|
+
# stealth:老师设成“能打开但课程页上不显示”,同步按课程页走,本来就不下
|
|
132
|
+
want = [i for i, cm in cms.items() if cm.get("module") == "resource" and cm.get("url")
|
|
133
|
+
and not cm.get("stealth")]
|
|
134
|
+
total += len(want)
|
|
135
|
+
lost = [i for i in want if i not in have]
|
|
136
|
+
for i in lost[:RESOLVE_MAX]:
|
|
137
|
+
# 录像默认不下载、只记链接:问一下 Moodle 文件名,是录像就不算漏
|
|
138
|
+
# 文件名看本站最后那个地址(同步也是按它判断);CloudFront 的地址常常没有扩展名
|
|
139
|
+
local, external, _ = ctx.client.resolve(cms[i]["url"] + "&redirect=1")
|
|
140
|
+
names = [u.split("?", 1)[0].lower() for u in (local, external) if u]
|
|
141
|
+
if not videos and any(n.endswith(VIDEO_EXT) for n in names):
|
|
142
|
+
expected += 1
|
|
143
|
+
else:
|
|
144
|
+
missing.append(f"{course_code(c)} cmid {i}")
|
|
145
|
+
missing += [f"{course_code(c)} cmid {i}" for i in lost[RESOLVE_MAX:]]
|
|
146
|
+
stale += sorted({f"{course_code(c)} cmid {r['cmid']}" for r in rows if r["cmid"] and r["cmid"] not in cms})
|
|
147
|
+
gone_local += [r["path"] for r in rows if not (FILES_DIR / r["path"]).exists()]
|
|
148
|
+
detail = f"{len(ctx.tracked)} 门课,Moodle 上 {total} 个文件资源" + (f"({expected} 个是没下载的录像)" if expected else "")
|
|
149
|
+
problems = []
|
|
150
|
+
if missing:
|
|
151
|
+
problems.append(f"Moodle 上有、本地没有:{_examples(missing)}")
|
|
152
|
+
if stale:
|
|
153
|
+
problems.append(f"Moodle 上已经没有、本地还当作在:{_examples(stale)}")
|
|
154
|
+
if gone_local:
|
|
155
|
+
problems.append(f"库里有记录、磁盘上找不到的文件 {len(gone_local)} 个")
|
|
156
|
+
if problems:
|
|
157
|
+
return _check(name, WARN, f"{detail};" + ";".join(problems), "monash sync;同步完还是这样就发给维护的人")
|
|
158
|
+
return _check(name, OK, detail + ",本地都有")
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def deep_index(ctx: Context, probes: int = 5) -> dict:
|
|
162
|
+
name = "核对:全文搜索"
|
|
163
|
+
con = ctx.con
|
|
164
|
+
CI.ensure_schema(con)
|
|
165
|
+
known = {r["path"]: r for r in con.execute("SELECT path, mtime, size, chunks, course FROM content_files")}
|
|
166
|
+
files = [f for f in CI.candidates() if Path(f["path"]).exists()
|
|
167
|
+
and Path(f["path"]).stat().st_size <= CI.MAX_FILE]
|
|
168
|
+
not_indexed = [f for f in files if f["path"] not in known
|
|
169
|
+
or (known[f["path"]]["mtime"], known[f["path"]]["size"])
|
|
170
|
+
!= (Path(f["path"]).stat().st_mtime, Path(f["path"]).stat().st_size)]
|
|
171
|
+
orphans = [p for p in known if not Path(p).exists()]
|
|
172
|
+
chunk_counts = dict(con.execute("SELECT path, count(*) FROM content_fts GROUP BY path").fetchall())
|
|
173
|
+
broken = [p for p, r in known.items() if (r["chunks"] or 0) > 0 and chunk_counts.get(p, 0) != r["chunks"]]
|
|
174
|
+
|
|
175
|
+
# 搜索本身:从随机几个文件里各挑一个词去搜,原文件应该在结果里
|
|
176
|
+
hits = tried = 0
|
|
177
|
+
sample = [p for p in chunk_counts if Path(p).exists()]
|
|
178
|
+
for path in random.Random(0).sample(sample, min(probes, len(sample))):
|
|
179
|
+
row = con.execute("SELECT text FROM content_fts WHERE path = ? LIMIT 1", (path,)).fetchone()
|
|
180
|
+
words = WORD_RE.findall(URL_RE.sub(" ", row[0] if row else ""))
|
|
181
|
+
if not words:
|
|
182
|
+
continue
|
|
183
|
+
tried += 1
|
|
184
|
+
word = max(words, key=len)
|
|
185
|
+
found = [h for h in CI.search(con, word, course=known[path]["course"] if path in known else None, limit=500)
|
|
186
|
+
if h["abs_path"] == path]
|
|
187
|
+
# 搜到了还不够:给 read_file 的路径也要能找回同一个文件
|
|
188
|
+
if found and found[0]["path"] and CI.resolve_readable(found[0]["path"]) == Path(path).resolve():
|
|
189
|
+
hits += 1
|
|
190
|
+
detail = f"{len(files)} 个文件,已索引 {len(files) - len(not_indexed)} 个;抽查搜索 {hits}/{tried} 次搜回原文件"
|
|
191
|
+
problems = []
|
|
192
|
+
if not_indexed:
|
|
193
|
+
problems.append(f"{len(not_indexed)} 个还没进索引(加密或损坏的文件也会这样)")
|
|
194
|
+
if orphans:
|
|
195
|
+
problems.append(f"索引里 {len(orphans)} 个文件已经不在磁盘上")
|
|
196
|
+
if broken:
|
|
197
|
+
problems.append(f"{len(broken)} 个文件的索引不完整")
|
|
198
|
+
if tried and hits < tried:
|
|
199
|
+
problems.append("有的词搜不回原文件")
|
|
200
|
+
if problems:
|
|
201
|
+
status = WARN if not (broken or (tried and hits < tried)) else FAIL
|
|
202
|
+
return _check(name, status, f"{detail};" + ";".join(problems), "monash sync 会重建索引;还不行就发给维护的人")
|
|
203
|
+
return _check(name, OK, detail)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def deep_ed(ctx: Context, limit: int = 30) -> dict:
|
|
207
|
+
name = "核对:Ed 帖子"
|
|
208
|
+
if not jobs.have_ed_token():
|
|
209
|
+
return _check(name, WARN, "没有 Ed 令牌,跳过", "monash login ed")
|
|
210
|
+
client, conn = edlib.EdClient(), edlib.db_connect()
|
|
211
|
+
try:
|
|
212
|
+
courses = conn.execute("SELECT id, code FROM courses WHERE tracked=1").fetchall()
|
|
213
|
+
run = conn.execute("SELECT started_at FROM runs ORDER BY id DESC LIMIT 1").fetchone()
|
|
214
|
+
since = _parse_time(run["started_at"] if run else None)
|
|
215
|
+
missing, stale, newer, total = [], [], 0, 0
|
|
216
|
+
for c in courses:
|
|
217
|
+
live = client.get(f"/courses/{c['id']}/threads", {"limit": limit, "offset": 0, "sort": "new"}).get("threads", [])
|
|
218
|
+
local = {r["id"]: r["content_hash"] for r in
|
|
219
|
+
conn.execute("SELECT id, content_hash FROM threads WHERE course_id=?", (c["id"],))}
|
|
220
|
+
code = edsync.short_code(c["code"])
|
|
221
|
+
total += len(live)
|
|
222
|
+
for t in live:
|
|
223
|
+
if t["id"] in local and local[t["id"]] == edsync.content_stamp(t):
|
|
224
|
+
continue
|
|
225
|
+
# 上次同步开始之后才发或才改的帖子,下次同步就有了,不算漏
|
|
226
|
+
changed = _parse_time(t.get("updated_at") or t.get("created_at"))
|
|
227
|
+
if since and changed and changed >= since:
|
|
228
|
+
newer += 1
|
|
229
|
+
elif t["id"] in local:
|
|
230
|
+
stale.append(f"{code}#{t.get('number')}")
|
|
231
|
+
else:
|
|
232
|
+
missing.append(f"{code}#{t.get('number')}")
|
|
233
|
+
finally:
|
|
234
|
+
conn.close()
|
|
235
|
+
detail = f"{len(courses)} 门课最新的 {total} 个帖子" + (f"({newer} 个是上次同步之后才有变化的)" if newer else "")
|
|
236
|
+
if missing or stale:
|
|
237
|
+
parts = ([f"本地没有:{_examples(missing)}"] if missing else []) + \
|
|
238
|
+
([f"本地是旧的:{_examples(stale)}"] if stale else [])
|
|
239
|
+
hours = jobs.hours_since_sync()
|
|
240
|
+
fix = "monash sync" + ("(上次同步是 1 小时内,还对不上就发给维护的人)" if hours < 1 else "")
|
|
241
|
+
return _check(name, WARN, f"{detail};" + ";".join(parts), fix)
|
|
242
|
+
return _check(name, OK, detail + ",本地都是最新的")
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def deep_grades(ctx: Context) -> dict:
|
|
246
|
+
name = "核对:成绩"
|
|
247
|
+
seen = {g["course_id"] for g in F.grades_overview(ctx.client)}
|
|
248
|
+
lost = [course_code(c) or str(c["id"]) for c in ctx.tracked if c["id"] not in seen]
|
|
249
|
+
detail = f"成绩总览里 {len(seen)} 门课"
|
|
250
|
+
if lost:
|
|
251
|
+
return _check(name, WARN, f"{detail};跟踪的课不在里面:{_examples(lost)}",
|
|
252
|
+
"刚开学的课可能还没有成绩表;一直这样就发给维护的人")
|
|
253
|
+
return _check(name, OK, detail + ",跟踪的课都在")
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
DEEP_CHECKS = [deep_due, deep_assignments, deep_files, deep_index, deep_ed, deep_grades]
|
|
257
|
+
NAMES = {"deep_due": "核对:截止日期", "deep_assignments": "核对:作业", "deep_files": "核对:课件",
|
|
258
|
+
"deep_index": "核对:全文搜索", "deep_ed": "核对:Ed 帖子", "deep_grades": "核对:成绩"}
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def run() -> list[dict]:
|
|
262
|
+
"""每项独立,一项出错不影响别的。Moodle 没登录就只做不需要 Moodle 的那几项。"""
|
|
263
|
+
ctx = Context()
|
|
264
|
+
out = []
|
|
265
|
+
try:
|
|
266
|
+
for fn in DEEP_CHECKS:
|
|
267
|
+
try:
|
|
268
|
+
out.append(fn(ctx))
|
|
269
|
+
except MoodleAuthError:
|
|
270
|
+
out.append(_check(NAMES[fn.__name__], FAIL, "Moodle 没登录,核对不了", "monash login"))
|
|
271
|
+
except Exception as e: # noqa: BLE001
|
|
272
|
+
out.append(_check(NAMES[fn.__name__], WARN, f"核对时出错:{type(e).__name__}: {scrub(str(e))[:120]}"))
|
|
273
|
+
finally:
|
|
274
|
+
ctx.con.close()
|
|
275
|
+
return out
|