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,231 @@
1
+ """Ed 课程内容(Lessons)抓取和存储,独立于 threads/replies 那一套。
2
+
3
+ 表结构用 CREATE TABLE IF NOT EXISTS 追加在同一个 ed.db 里。课件 PDF 下载到 ED_FILES_DIR,
4
+ 全文索引(content_index)会一起索引。
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import os
10
+ import re
11
+ import shutil
12
+ import sqlite3
13
+ import urllib.request
14
+
15
+ from .edlib import UA, EdClient, doc_to_text, now_iso
16
+ from .paths import ED_FILES_DIR as FILES_DIR
17
+ _UNSAFE_NAME = re.compile(r'[\\/:*?"<>|]+')
18
+
19
+
20
+ def _safe_name(name: str) -> str:
21
+ name = _UNSAFE_NAME.sub("_", name or "file").strip() or "file"
22
+ return name[:120]
23
+
24
+
25
+ def download_pdf(url: str, dest, log=print) -> bool:
26
+ """下载一个 PDF。已经存在就跳过,不重复下载。file_url 是 Ed 那边签发的直链,
27
+ 不用带 x-token(这个域名不是 edstem.org,带了反而可能被拒)。
28
+
29
+ 先写到 .part 再改名:下到一半断网的话,留下的半个文件不会被当成“已经下好了”。"""
30
+ if dest.exists():
31
+ return True
32
+ dest.parent.mkdir(parents=True, exist_ok=True)
33
+ tmp = dest.with_name(dest.name + ".part")
34
+ try:
35
+ req = urllib.request.Request(url, headers={"user-agent": UA})
36
+ with urllib.request.urlopen(req, timeout=60) as resp, open(tmp, "wb") as f:
37
+ while chunk := resp.read(1 << 16):
38
+ f.write(chunk)
39
+ os.replace(tmp, dest)
40
+ return True
41
+ except Exception as e: # noqa: BLE001
42
+ tmp.unlink(missing_ok=True)
43
+ log(f" Ed 课件下载失败 {dest.name}: {e}")
44
+ return False
45
+
46
+ LESSONS_SCHEMA = """
47
+ CREATE TABLE IF NOT EXISTS modules (
48
+ id INTEGER PRIMARY KEY, course_id INTEGER, name TEXT, created_at TEXT
49
+ );
50
+ CREATE TABLE IF NOT EXISTS lessons (
51
+ id INTEGER PRIMARY KEY, course_id INTEGER, module_id INTEGER, idx INTEGER,
52
+ kind TEXT, type TEXT, title TEXT, status TEXT, slide_count INTEGER,
53
+ is_hidden INTEGER, is_timed INTEGER, timer_duration INTEGER,
54
+ due_at TEXT, effective_due_at TEXT, last_synced TEXT
55
+ );
56
+ CREATE INDEX IF NOT EXISTS idx_lessons_course ON lessons(course_id, module_id);
57
+ CREATE TABLE IF NOT EXISTS lesson_files (
58
+ id INTEGER PRIMARY KEY, lesson_id INTEGER, course_id INTEGER, type TEXT,
59
+ title TEXT, file_url TEXT, url TEXT, local_path TEXT, idx INTEGER -- idx:slide 在 lesson 里的顺序
60
+ );
61
+ CREATE INDEX IF NOT EXISTS idx_lesson_files_lesson ON lesson_files(lesson_id);
62
+ -- lesson 里 quiz 页的题目和选项(Markdown)。Ed 不给学生看答案(release_quiz_solutions 都是 false),
63
+ -- 作答记录的接口也是空的,所以只有题面,拿来当复习清单。
64
+ CREATE TABLE IF NOT EXISTS quiz_questions (
65
+ id INTEGER PRIMARY KEY, slide_id INTEGER, lesson_id INTEGER, course_id INTEGER,
66
+ slide_title TEXT, idx INTEGER, type TEXT, multiple INTEGER, question TEXT, options TEXT,
67
+ fetched_at TEXT
68
+ );
69
+ CREATE INDEX IF NOT EXISTS idx_quiz_lesson ON quiz_questions(lesson_id, slide_id, idx);
70
+ """
71
+
72
+
73
+ def ensure_lessons_schema(conn: sqlite3.Connection) -> None:
74
+ conn.executescript(LESSONS_SCHEMA)
75
+ cols = {r[1] for r in conn.execute("PRAGMA table_info(lesson_files)")}
76
+ if "idx" not in cols: # 0.3 以前的库没有页序,下次同步会补上
77
+ conn.execute("ALTER TABLE lesson_files ADD COLUMN idx INTEGER")
78
+
79
+
80
+ def fetch_lessons(client: EdClient, course_id: int) -> tuple[list[dict], list[dict]]:
81
+ """对应 GET /courses/{id}/lessons,跟 threads() 一样直接用 client.get()。"""
82
+ data = client.get(f"/courses/{course_id}/lessons")
83
+ modules = sorted(data.get("modules", []), key=lambda m: m["id"])
84
+ lessons = [l for l in data.get("lessons", []) if not l.get("is_hidden")]
85
+ lessons.sort(key=lambda l: (l.get("index") is None, l.get("index") or 0))
86
+ return modules, lessons
87
+
88
+
89
+ def fetch_slides(client: EdClient, lesson_id: int) -> list[dict] | None:
90
+ """对应 GET /lessons/{id},跟 ed-web 里 fetchLessonFiles() 用的是同一个接口。
91
+ 列表接口(/courses/{id}/lessons)不带 slides,pdf 的 file_url / webpage 的 url
92
+ 都要进这个详情接口才有。
93
+
94
+ 请求失败返回 None(不是空列表):调用方据此保留上次存的文件和测验题,
95
+ 不然一次网络抖动就会把这节 lesson 的课件清单和题目全删掉。"""
96
+ try:
97
+ data = client.get(f"/lessons/{lesson_id}")
98
+ return [s for s in (data.get("lesson") or {}).get("slides", []) if not s.get("is_hidden")]
99
+ except Exception as e:
100
+ if type(e).__name__ == "EdAuthError":
101
+ raise
102
+ return None
103
+
104
+
105
+ def fetch_lesson_files(client: EdClient, lesson_id: int, slides: list[dict] | None = None) -> list[dict]:
106
+ if slides is None:
107
+ slides = fetch_slides(client, lesson_id) or []
108
+ out = []
109
+ for pos, s in enumerate(slides):
110
+ idx = s.get("index") if isinstance(s.get("index"), int) else pos
111
+ if s.get("type") == "pdf" and s.get("file_url"):
112
+ out.append({"id": s["id"], "type": "pdf", "title": s.get("title") or "",
113
+ "file_url": s["file_url"], "url": None, "idx": idx})
114
+ elif s.get("type") == "webpage" and s.get("url"):
115
+ out.append({"id": s["id"], "type": "webpage", "title": s.get("title") or "",
116
+ "file_url": None, "url": s["url"], "idx": idx})
117
+ elif s.get("type") in ("document", "code") and (text := slide_text(s)):
118
+ # 正文直接写在 Ed 里的页:存成 .md,全文索引就能搜到
119
+ out.append({"id": s["id"], "type": s["type"], "title": s.get("title") or "",
120
+ "file_url": None, "url": None, "text": text, "idx": idx})
121
+ return out
122
+
123
+
124
+ def slide_text(slide: dict) -> str:
125
+ """document / code 页的正文(字段位置不固定:content、passage,或者包在 data 里)。"""
126
+ data = slide.get("data") if isinstance(slide.get("data"), dict) else {}
127
+ raw = slide.get("content") or slide.get("passage") or data.get("content") or data.get("passage") or ""
128
+ text = doc_to_text(raw) if isinstance(raw, str) else ""
129
+ return text if len(text.strip()) >= 40 else "" # 只有一个链接的页没什么可搜的
130
+
131
+
132
+ def sync_quizzes(conn: sqlite3.Connection, client: EdClient, course_id: int, lesson_id: int,
133
+ slides: list[dict], log=print) -> int:
134
+ """quiz 页的题目。已经存过的 quiz 页不再重抓(题目基本不改,每小时抓一遍太浪费);
135
+ lesson 里删掉的 quiz 页,题目跟着删。返回新抓的题数。"""
136
+ quiz = {s["id"]: s for s in slides if s.get("type") == "quiz"}
137
+ have = {r[0] for r in conn.execute("SELECT DISTINCT slide_id FROM quiz_questions WHERE lesson_id=?", (lesson_id,))}
138
+ for gone in have - set(quiz):
139
+ conn.execute("DELETE FROM quiz_questions WHERE slide_id=?", (gone,))
140
+ n = 0
141
+ for sid, s in quiz.items():
142
+ if sid in have:
143
+ continue
144
+ try:
145
+ qs = client.get(f"/lessons/slides/{sid}/questions").get("questions", [])
146
+ except Exception as e:
147
+ if type(e).__name__ == "EdAuthError":
148
+ raise
149
+ log(f" quiz {sid} 取题失败: {e}")
150
+ continue
151
+ ts = now_iso()
152
+ for q in qs:
153
+ d = q.get("data") or {}
154
+ conn.execute("INSERT OR REPLACE INTO quiz_questions VALUES (?,?,?,?,?,?,?,?,?,?,?)", (
155
+ q["id"], sid, lesson_id, course_id, s.get("title"), q.get("index"), d.get("type"),
156
+ int(bool(d.get("multiple_selection"))), doc_to_text(d.get("content")),
157
+ json.dumps([doc_to_text(a) for a in d.get("answers") or []], ensure_ascii=False), ts))
158
+ n += 1
159
+ return n
160
+
161
+
162
+ def prune_lessons(conn: sqlite3.Connection, course_id: int, keep: set[int]) -> int:
163
+ """Ed 上删掉或藏起来的 lesson,连同它的课件记录、测验题和下载下来的文件一起去掉。返回删了几节。"""
164
+ gone = [r[0] for r in conn.execute("SELECT id FROM lessons WHERE course_id=?", (course_id,))
165
+ if r[0] not in keep]
166
+ for lid in gone:
167
+ conn.execute("DELETE FROM lesson_files WHERE lesson_id=?", (lid,))
168
+ conn.execute("DELETE FROM quiz_questions WHERE lesson_id=?", (lid,))
169
+ conn.execute("DELETE FROM lessons WHERE id=?", (lid,))
170
+ shutil.rmtree(FILES_DIR / str(course_id) / str(lid), ignore_errors=True) # 全文索引下次就不再搜到
171
+ return len(gone)
172
+
173
+
174
+ def sync_lessons(conn: sqlite3.Connection, client: EdClient, course_id: int, log=print) -> tuple[int, int]:
175
+ """全量覆盖这门课的 modules/lessons。数据量小(一门课几十条),不用跟 threads
176
+ 那样做增量,Ed 的 lesson.updated_at 目前观察下来基本不维护,没法当指纹用。"""
177
+ ensure_lessons_schema(conn)
178
+ modules, lessons = fetch_lessons(client, course_id)
179
+ ts = now_iso()
180
+ prune_lessons(conn, course_id, {l["id"] for l in lessons})
181
+
182
+ for m in modules:
183
+ conn.execute(
184
+ "INSERT OR REPLACE INTO modules (id, course_id, name, created_at) "
185
+ "VALUES (?, ?, ?, ?)",
186
+ (m["id"], course_id, m.get("name"), m.get("created_at")),
187
+ )
188
+ for l in lessons:
189
+ conn.execute(
190
+ "INSERT OR REPLACE INTO lessons "
191
+ "(id, course_id, module_id, idx, kind, type, title, status, slide_count, "
192
+ " is_hidden, is_timed, timer_duration, due_at, effective_due_at, last_synced) "
193
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
194
+ (
195
+ l["id"], course_id, l.get("module_id"), l.get("index"),
196
+ l.get("kind"), l.get("type"), l.get("title"), l.get("status"),
197
+ l.get("slide_count"), int(bool(l.get("is_hidden"))),
198
+ int(bool(l.get("is_timed"))), l.get("timer_duration"),
199
+ l.get("due_at"), l.get("effective_due_at"), ts,
200
+ ),
201
+ )
202
+ slides = fetch_slides(client, l["id"])
203
+ if slides is None:
204
+ log(f" lesson {l['id']} 的内容没取到,先保留上次的")
205
+ continue
206
+ conn.execute("DELETE FROM lesson_files WHERE lesson_id = ?", (l["id"],))
207
+ sync_quizzes(conn, client, course_id, l["id"], slides, log)
208
+ for f in fetch_lesson_files(client, l["id"], slides):
209
+ local_path = None
210
+ if f["type"] == "pdf" and f["file_url"]:
211
+ fname = f"{f['id']}_{_safe_name(f['title'] or 'slides')}.pdf"
212
+ rel = f"{course_id}/{l['id']}/{fname}"
213
+ if download_pdf(f["file_url"], FILES_DIR / rel, log):
214
+ local_path = rel
215
+ elif f.get("text"):
216
+ rel = f"{course_id}/{l['id']}/{f['id']}_{_safe_name(f['title'] or f['type'])}.md"
217
+ dest = FILES_DIR / rel
218
+ dest.parent.mkdir(parents=True, exist_ok=True)
219
+ body = f"# {l.get('title')} · {f['title']}\n\n{f['text']}\n"
220
+ if not dest.exists() or dest.read_text(encoding="utf-8") != body:
221
+ dest.write_text(body, encoding="utf-8")
222
+ local_path = rel
223
+ conn.execute(
224
+ "INSERT OR REPLACE INTO lesson_files "
225
+ "(id, lesson_id, course_id, type, title, file_url, url, local_path, idx) "
226
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
227
+ (f["id"], l["id"], course_id, f["type"], f["title"], f["file_url"], f["url"], local_path,
228
+ f.get("idx")),
229
+ )
230
+ conn.commit()
231
+ return len(modules), len(lessons)