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,312 @@
|
|
|
1
|
+
"""从 Ed 的 "Week N ... Recording" 帖子里把录播拿回来,放进 Moodle 课件的周目录。
|
|
2
|
+
|
|
3
|
+
Zoom 录像 → 用帖子里的 Passcode 走一遍网页流程,下载 mp4(之后交给 transcribe.py 识别)
|
|
4
|
+
YouTube → 不下视频,直接取 YouTube 自动字幕,写成 .transcript.md
|
|
5
|
+
|
|
6
|
+
帖子来自本地的 ed.db。Ed 课程和 Moodle 课程按课号对应(FIT2102 S2 2026 Malaysia → FIT2102)。
|
|
7
|
+
链接用 Ed 令牌调 /api/threads/{id} 拿原始内容(库里的正文是转过的 Markdown,偶尔会漏);
|
|
8
|
+
拿不到就退回库里的正文。
|
|
9
|
+
|
|
10
|
+
YouTube 字幕借 yt-dlp(装了 [media] 才有;它解 YouTube 要 deno,也在 [media] 里)。
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import html
|
|
15
|
+
import http.cookiejar
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import re
|
|
19
|
+
import sqlite3
|
|
20
|
+
import subprocess
|
|
21
|
+
import sys
|
|
22
|
+
import tempfile
|
|
23
|
+
import urllib.parse
|
|
24
|
+
import urllib.request
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
from . import edlib
|
|
28
|
+
from .edlib import DB_PATH as ED_DB
|
|
29
|
+
from .moodlelib import FILES_DIR, MoodleClient, get_state, now_iso, set_state
|
|
30
|
+
from .syncer import clean, course_folder, plan_layout, safe_name, week_folder
|
|
31
|
+
|
|
32
|
+
BIN_DIR = Path(sys.executable).parent # 装在同一个环境里的 deno 在这里(Windows 是 Scripts)
|
|
33
|
+
UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0 Safari/537.36"
|
|
34
|
+
|
|
35
|
+
ZOOM_RE = re.compile(r"https://[\w.-]*zoom\.us/rec/(?:share|play)/[^\s<>\"')\]]+")
|
|
36
|
+
YT_RE = re.compile(r"https://(?:youtu\.be/|(?:www\.)?youtube\.com/watch\?v=)([\w-]{11})")
|
|
37
|
+
PASS_RE = re.compile(r"Pass(?:code|word)\s*:?\s*([^\s<]+)", re.I)
|
|
38
|
+
WEEK_RE = re.compile(r"\bweek\s*0*(\d+)", re.I)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# ---------------------------------------------------------------- 从 Ed 找录播
|
|
42
|
+
|
|
43
|
+
def _ed_raw(thread_id: int) -> str | None:
|
|
44
|
+
"""Ed 原始内容(带超链接)。不能带 ?view=1,那会把帖子的 updated_at 顶成现在。"""
|
|
45
|
+
try:
|
|
46
|
+
return edlib.EdClient().thread(thread_id)["thread"]["content"]
|
|
47
|
+
except Exception: # noqa: BLE001 令牌失效、网络问题:退回库里的正文
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def extract_links(content: str) -> list[dict]:
|
|
52
|
+
"""从一段 Ed 内容里按出现顺序取出 Zoom / YouTube 链接,Zoom 配上它后面最近的 Passcode。"""
|
|
53
|
+
text = html.unescape(content)
|
|
54
|
+
found = []
|
|
55
|
+
for m in re.finditer(r'href="([^"]+)"|(https://[^\s<>"]+)', text):
|
|
56
|
+
url = (m.group(1) or m.group(2)).rstrip(".,;")
|
|
57
|
+
pos = m.end()
|
|
58
|
+
if ZOOM_RE.match(url):
|
|
59
|
+
url = ZOOM_RE.match(url).group(0)
|
|
60
|
+
pw = PASS_RE.search(re.sub(r"<[^>]+>", " ", text[pos:pos + 400]))
|
|
61
|
+
found.append({"kind": "zoom", "url": url, "passcode": pw.group(1) if pw else None})
|
|
62
|
+
elif YT_RE.match(url):
|
|
63
|
+
found.append({"kind": "youtube", "url": url, "video_id": YT_RE.match(url).group(1)})
|
|
64
|
+
out, seen = [], set()
|
|
65
|
+
for f in found:
|
|
66
|
+
key = f.get("video_id") or f["url"].split("?")[0]
|
|
67
|
+
if key not in seen:
|
|
68
|
+
seen.add(key)
|
|
69
|
+
out.append(f)
|
|
70
|
+
return out
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def ed_recordings() -> list[dict]:
|
|
74
|
+
if not ED_DB.exists():
|
|
75
|
+
return []
|
|
76
|
+
con = sqlite3.connect(f"file:{ED_DB.as_posix()}?mode=ro", uri=True)
|
|
77
|
+
rows = con.execute("SELECT t.id, c.code, t.number, t.title, t.body FROM threads t"
|
|
78
|
+
" JOIN courses c ON c.id = t.course_id"
|
|
79
|
+
" WHERE c.tracked = 1 AND t.title LIKE '%Recording%' ORDER BY t.created_at").fetchall()
|
|
80
|
+
con.close()
|
|
81
|
+
out = []
|
|
82
|
+
for tid, ed_code, number, title, body in rows:
|
|
83
|
+
m = re.search(r"[A-Z]{3}\d{4}", ed_code or "")
|
|
84
|
+
code = m.group(0) if m else None
|
|
85
|
+
week = WEEK_RE.search(title or "")
|
|
86
|
+
if not code or not week:
|
|
87
|
+
continue
|
|
88
|
+
links = extract_links(_ed_raw(tid) or body or "")
|
|
89
|
+
for i, link in enumerate(links, 1):
|
|
90
|
+
out.append({**link, "code": code, "week": int(week.group(1)), "title": clean(title), "ed_number": number,
|
|
91
|
+
"part": i if len(links) > 1 else None})
|
|
92
|
+
return out
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# ---------------------------------------------------------------- Zoom
|
|
96
|
+
|
|
97
|
+
def zoom_download(share_url: str, passcode: str | None, dest_for, exists=lambda k: False) -> list[dict]:
|
|
98
|
+
"""Zoom 录像分享页:验证密码 → share-info 拿播放页 → play/info 拿 mp4 地址 → 下载。
|
|
99
|
+
|
|
100
|
+
一场会议中途断开重连,Zoom 会把录像拆成好几段(totalClips)。第一段常常只是开场几分钟,
|
|
101
|
+
正课在后面,所以要顺着 nextClipStartTime 把每一段都拿下来。dest_for(k, total) 给出第 k 段
|
|
102
|
+
存到哪;exists(k) 为真的段跳过(已经下过)。
|
|
103
|
+
|
|
104
|
+
yt-dlp 的 Zoom 解析在"验证密码之后"这一步跟不上现在的接口(2026-09),所以照网页自己走。
|
|
105
|
+
"""
|
|
106
|
+
jar = http.cookiejar.CookieJar()
|
|
107
|
+
op = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
|
|
108
|
+
op.addheaders = [("User-Agent", UA), ("Referer", share_url)]
|
|
109
|
+
base = "{0.scheme}://{0.netloc}".format(urllib.parse.urlsplit(share_url))
|
|
110
|
+
page = op.open(share_url, timeout=30).read().decode("utf-8", "replace")
|
|
111
|
+
mid = re.search(r"meetingId:\s*'([^']+)'", page)
|
|
112
|
+
if passcode and mid:
|
|
113
|
+
body = urllib.parse.urlencode({"id": mid.group(1), "passwd": passcode, "action": "viewdetailpage",
|
|
114
|
+
"recaptcha": ""}).encode()
|
|
115
|
+
v = json.loads(op.open(base + "/nws/recording/1.0/validate-meeting-passwd", body, timeout=30).read())
|
|
116
|
+
if not v.get("status"):
|
|
117
|
+
raise RuntimeError(f"Zoom 密码验证失败:{v.get('errorMessage') or v.get('errorCode')}")
|
|
118
|
+
if "/rec/share/" in share_url:
|
|
119
|
+
share_id = share_url.split("/rec/share/", 1)[1].split("?")[0]
|
|
120
|
+
si = json.loads(op.open(f"{base}/nws/recording/1.0/play/share-info/{share_id}", timeout=30).read())
|
|
121
|
+
m = re.search(r"/rec/play/([^?]+)", (si.get("result") or {}).get("redirectUrl") or "")
|
|
122
|
+
if not m:
|
|
123
|
+
raise RuntimeError(f"Zoom 没给出播放页:{si.get('errorMessage') or si.get('errorCode')}")
|
|
124
|
+
file_id = m.group(1)
|
|
125
|
+
else:
|
|
126
|
+
file_id = share_url.split("/rec/play/", 1)[1].split("?")[0]
|
|
127
|
+
info_url = (f"{base}/nws/recording/1.0/play/info/{file_id}?canPlayFromShare=true"
|
|
128
|
+
"&from=share_recording_detail&continueMode=true&componentName=rec-play")
|
|
129
|
+
out, start, seen = [], None, set()
|
|
130
|
+
while True:
|
|
131
|
+
info = json.loads(op.open(info_url + (f"&startTime={start}" if start else ""), timeout=30).read())
|
|
132
|
+
res = info.get("result") or {}
|
|
133
|
+
clip, total = int(res.get("currentClip") or 1), int(res.get("totalClips") or 1)
|
|
134
|
+
if clip in seen:
|
|
135
|
+
break
|
|
136
|
+
seen.add(clip)
|
|
137
|
+
mp4 = res.get("viewMp4Url") or res.get("mp4Url")
|
|
138
|
+
if not mp4:
|
|
139
|
+
raise RuntimeError(f"Zoom 没给出第 {clip} 段的视频地址:{info.get('errorMessage') or info.get('errorCode')}")
|
|
140
|
+
dest = dest_for(clip, total)
|
|
141
|
+
if not exists(clip):
|
|
142
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
143
|
+
tmp = dest.with_name(dest.name + ".part")
|
|
144
|
+
req = urllib.request.Request(mp4, headers={"User-Agent": UA, "Referer": base + "/"})
|
|
145
|
+
try:
|
|
146
|
+
with op.open(req, timeout=120) as r, open(tmp, "wb") as f:
|
|
147
|
+
while chunk := r.read(1 << 20):
|
|
148
|
+
f.write(chunk)
|
|
149
|
+
except BaseException:
|
|
150
|
+
tmp.unlink(missing_ok=True) # 几百 MB 的半截视频别留在周目录里
|
|
151
|
+
raise
|
|
152
|
+
os.replace(tmp, dest)
|
|
153
|
+
out.append({"clip": clip, "total": total, "path": dest, "duration": res.get("duration"),
|
|
154
|
+
"skipped": exists(clip)})
|
|
155
|
+
nxt = res.get("nextClipStartTime")
|
|
156
|
+
if not nxt or nxt == -1 or clip >= total:
|
|
157
|
+
break
|
|
158
|
+
start = nxt
|
|
159
|
+
return out
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# ---------------------------------------------------------------- YouTube 字幕
|
|
163
|
+
|
|
164
|
+
def youtube_transcript(video_id: str) -> tuple[str, list[tuple[float, str]]]:
|
|
165
|
+
"""取 YouTube 英文字幕(人工的优先,其次自动生成的)。返回 (标题, [(秒, 文字)])。"""
|
|
166
|
+
with tempfile.TemporaryDirectory() as d:
|
|
167
|
+
env = {**os.environ, "PATH": f"{BIN_DIR}{os.pathsep}{os.environ.get('PATH', '')}"}
|
|
168
|
+
cmd = [sys.executable, "-m", "yt_dlp", "--skip-download", "--write-subs", "--write-auto-subs",
|
|
169
|
+
"--sub-langs", "en.*,en", "--sub-format", "json3", "--print", "title", "--no-simulate",
|
|
170
|
+
"-o", f"{d}/%(id)s.%(ext)s", f"https://youtu.be/{video_id}"]
|
|
171
|
+
r = subprocess.run(cmd, capture_output=True, text=True, env=env, timeout=300)
|
|
172
|
+
subs = sorted(Path(d).glob("*.json3"), key=lambda p: ("orig" in p.name, "-" in p.stem.split(".")[-1]))
|
|
173
|
+
if not subs:
|
|
174
|
+
raise RuntimeError(f"YouTube 没有英文字幕:{(r.stderr or r.stdout)[-300:]}")
|
|
175
|
+
title = (r.stdout.strip().splitlines() or [video_id])[0]
|
|
176
|
+
events = json.loads(subs[0].read_text(encoding="utf-8")).get("events", [])
|
|
177
|
+
lines = []
|
|
178
|
+
for e in events:
|
|
179
|
+
text = "".join(s.get("utf8", "") for s in e.get("segs") or []).replace("\n", " ").strip()
|
|
180
|
+
if text:
|
|
181
|
+
lines.append((e.get("tStartMs", 0) / 1000, text))
|
|
182
|
+
return title, lines
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
# ---------------------------------------------------------------- 字幕稿格式(transcribe.py 也用)
|
|
186
|
+
|
|
187
|
+
def fmt_ts(sec: float) -> str:
|
|
188
|
+
sec = int(sec)
|
|
189
|
+
return f"{sec // 3600}:{sec % 3600 // 60:02d}:{sec % 60:02d}" if sec >= 3600 else f"{sec // 60:02d}:{sec % 60:02d}"
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def paragraphs(lines: list[tuple[float, str]], every: float = 45.0) -> list[tuple[float, str]]:
|
|
193
|
+
"""把一句句的字幕并成段落:每段大约 45 秒,碰到句号才断,读起来不碎。"""
|
|
194
|
+
out, start, buf = [], None, []
|
|
195
|
+
for t, text in lines:
|
|
196
|
+
if start is None:
|
|
197
|
+
start = t
|
|
198
|
+
buf.append(text)
|
|
199
|
+
if t - start >= every and re.search(r"[.?!]$", text):
|
|
200
|
+
out.append((start, " ".join(buf)))
|
|
201
|
+
start, buf = None, []
|
|
202
|
+
if buf:
|
|
203
|
+
out.append((start or 0, " ".join(buf)))
|
|
204
|
+
return out
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def write_transcript(path: Path, title: str, meta: list[str], lines: list[tuple[float, str]]) -> None:
|
|
208
|
+
body = [f"# {title} 字幕稿", "", " · ".join(m for m in meta if m), "",
|
|
209
|
+
"> 自动生成的字幕,专有名词和代码可能听错;有疑问以视频为准。", ""]
|
|
210
|
+
paras = paragraphs(lines)
|
|
211
|
+
if not paras or sum(len(p.split()) for _, p in paras) < 20:
|
|
212
|
+
body.append("(没有识别到讲话,可能是演示动画或没有声音的录屏。)")
|
|
213
|
+
for t, text in paras:
|
|
214
|
+
body += [f"**[{fmt_ts(t)}]** {text}", ""]
|
|
215
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
216
|
+
tmp = path.with_name(path.name + ".tmp")
|
|
217
|
+
tmp.write_text("\n".join(body).rstrip() + "\n", encoding="utf-8")
|
|
218
|
+
os.replace(tmp, path)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
# ---------------------------------------------------------------- 入口
|
|
222
|
+
|
|
223
|
+
def week_folders(client: MoodleClient, course: dict) -> dict[int, str]:
|
|
224
|
+
"""周次 → 这门课在 Moodle 上的周目录名(和 sync 用的一致)。"""
|
|
225
|
+
layout = plan_layout(client.course_state(course["id"]))
|
|
226
|
+
out = {}
|
|
227
|
+
for folder in set(layout.folders.values()):
|
|
228
|
+
m = re.match(r"Week (\d+)", folder)
|
|
229
|
+
if m and "/" not in folder:
|
|
230
|
+
out.setdefault(int(m.group(1)), folder)
|
|
231
|
+
return out
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def register(con, course_id: int, source: str, rel: str, section: str, title: str) -> None:
|
|
235
|
+
size = (FILES_DIR / rel).stat().st_size
|
|
236
|
+
con.execute("INSERT INTO files (course_id, source, cmid, path, size, section, title, synced_at)"
|
|
237
|
+
" VALUES (?,?,NULL,?,?,?,?,?) ON CONFLICT(course_id, source) DO UPDATE SET"
|
|
238
|
+
" path=excluded.path, size=excluded.size, synced_at=excluded.synced_at",
|
|
239
|
+
(course_id, source, rel, size, section, title, now_iso()))
|
|
240
|
+
con.commit()
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def sync_recordings(client: MoodleClient, con, log=print) -> dict:
|
|
244
|
+
from .features import find_course
|
|
245
|
+
courses = client.courses()
|
|
246
|
+
stats = {"zoom": 0, "youtube": 0, "skipped": 0, "errors": []}
|
|
247
|
+
folders_cache: dict[int, dict[int, str]] = {}
|
|
248
|
+
for rec in ed_recordings():
|
|
249
|
+
try:
|
|
250
|
+
course = find_course(courses, rec["code"])
|
|
251
|
+
except LookupError:
|
|
252
|
+
continue
|
|
253
|
+
cid = course["id"]
|
|
254
|
+
if cid not in folders_cache:
|
|
255
|
+
folders_cache[cid] = week_folders(client, course)
|
|
256
|
+
week_dir = folders_cache[cid].get(rec["week"]) or week_folder(f"Week {rec['week']}")
|
|
257
|
+
part = f" Part {rec['part']}" if rec["part"] else ""
|
|
258
|
+
label = f"Week {rec['week']:02d} online seminar{part} (Ed #{rec['ed_number']})"
|
|
259
|
+
rel_dir = f"{course_folder(course)}/{week_dir}"
|
|
260
|
+
source = f"ed:{rec.get('video_id') or rec['url'].split('?')[0]}"
|
|
261
|
+
done_key = f"ed-done:{source}"
|
|
262
|
+
if get_state(con, done_key):
|
|
263
|
+
stats["skipped"] += 1
|
|
264
|
+
continue
|
|
265
|
+
|
|
266
|
+
def clip_source(k):
|
|
267
|
+
return source if k == 1 else f"{source}#clip{k}"
|
|
268
|
+
|
|
269
|
+
def clip_rel(k, total):
|
|
270
|
+
return f"{rel_dir}/{safe_name(label + (f' clip {k} of {total}' if total > 1 else ''))}.mp4"
|
|
271
|
+
|
|
272
|
+
try:
|
|
273
|
+
if rec["kind"] == "zoom":
|
|
274
|
+
def exists(k):
|
|
275
|
+
row = con.execute("SELECT path FROM files WHERE course_id=? AND source=?",
|
|
276
|
+
(cid, clip_source(k))).fetchone()
|
|
277
|
+
return bool(row and (FILES_DIR / row["path"]).exists())
|
|
278
|
+
clips = zoom_download(rec["url"], rec.get("passcode"),
|
|
279
|
+
lambda k, total: FILES_DIR / clip_rel(k, total), exists)
|
|
280
|
+
for c in clips:
|
|
281
|
+
rel = c["path"].relative_to(FILES_DIR).as_posix()
|
|
282
|
+
if not c["skipped"]:
|
|
283
|
+
log(f" ↓ Zoom {rel}({fmt_ts(c['duration'] or 0)})")
|
|
284
|
+
stats["zoom"] += 1
|
|
285
|
+
# 第一次只下了第 1 段、文件名没带 clip 的,改成带 clip 的名字
|
|
286
|
+
old = con.execute("SELECT path FROM files WHERE course_id=? AND source=?",
|
|
287
|
+
(cid, clip_source(c["clip"]))).fetchone()
|
|
288
|
+
if old and old["path"] != rel and (FILES_DIR / old["path"]).exists():
|
|
289
|
+
os.replace(FILES_DIR / old["path"], FILES_DIR / rel)
|
|
290
|
+
register(con, cid, clip_source(c["clip"]), rel, rec["title"],
|
|
291
|
+
rec["title"] + (f"(第 {c['clip']}/{c['total']} 段)" if c["total"] > 1 else ""))
|
|
292
|
+
set_state(con, done_key, now_iso())
|
|
293
|
+
else:
|
|
294
|
+
title, lines = youtube_transcript(rec["video_id"])
|
|
295
|
+
rel = f"{rel_dir}/{safe_name(label)}.transcript.md"
|
|
296
|
+
log(f" ✎ YouTube 字幕 {rel}")
|
|
297
|
+
write_transcript(FILES_DIR / rel, f"{rec['title']}{part}",
|
|
298
|
+
[f"来源:Ed #{rec['ed_number']}", f"YouTube《{title}》 {rec['url']}",
|
|
299
|
+
"字幕:YouTube 自动字幕"], lines)
|
|
300
|
+
register(con, cid, source, rel, rec["title"], f"字幕稿:{rec['title']}{part}")
|
|
301
|
+
con.execute("INSERT OR IGNORE INTO links (course_id, folder, section, title, url, synced_at)"
|
|
302
|
+
" VALUES (?,?,?,?,?,?)", (cid, week_dir, "Recordings", f"{rec['title']}{part}",
|
|
303
|
+
rec["url"], now_iso()))
|
|
304
|
+
con.commit()
|
|
305
|
+
stats["youtube"] += 1
|
|
306
|
+
set_state(con, done_key, now_iso())
|
|
307
|
+
except Exception as e: # noqa: BLE001
|
|
308
|
+
if e.__class__.__name__ == "MoodleAuthError":
|
|
309
|
+
raise
|
|
310
|
+
stats["errors"].append(f"{label}: {e}")
|
|
311
|
+
log(f" ! {label}: {e}")
|
|
312
|
+
return stats
|