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