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,431 @@
|
|
|
1
|
+
"""查本地库:列帖子、搜帖子、读帖子、看新动态、课程内容。
|
|
2
|
+
|
|
3
|
+
CLI(monash ed …)和 MCP 服务器共用这里。只读 SQLite,不碰 Ed 的接口,库由 sync 维护
|
|
4
|
+
(MCP 在跑的时候后台每小时一次)。要最新状态就先 sync 或 show --live。
|
|
5
|
+
|
|
6
|
+
每个函数都返回普通的 dict/list,方便直接转 JSON。
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import re
|
|
12
|
+
import sqlite3
|
|
13
|
+
from datetime import datetime, timedelta, timezone
|
|
14
|
+
|
|
15
|
+
from . import edlib
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class NotFound(LookupError):
|
|
19
|
+
"""课程或帖子不存在(CLI 退出码 4)。"""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# --------------------------------------------------------------------------
|
|
23
|
+
# 参数解析
|
|
24
|
+
# --------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
def parse_since(value: str | None) -> str | None:
|
|
27
|
+
"""'7d' / '12h' / '2w' / '30m' 或 ISO 日期时间 -> UTC ISO 字符串。"""
|
|
28
|
+
if not value:
|
|
29
|
+
return None
|
|
30
|
+
m = re.fullmatch(r"\s*(\d+)\s*([mhdw])\s*", value)
|
|
31
|
+
if m:
|
|
32
|
+
n, unit = int(m.group(1)), m.group(2)
|
|
33
|
+
delta = {"m": timedelta(minutes=n), "h": timedelta(hours=n),
|
|
34
|
+
"d": timedelta(days=n), "w": timedelta(weeks=n)}[unit]
|
|
35
|
+
return (datetime.now(timezone.utc) - delta).isoformat(timespec="seconds")
|
|
36
|
+
try:
|
|
37
|
+
dt = datetime.fromisoformat(value.strip().replace("Z", "+00:00"))
|
|
38
|
+
except ValueError:
|
|
39
|
+
raise ValueError(f"看不懂的时间 “{value}”:用 7d / 12h / 2w 或 2026-09-01 这种格式") from None
|
|
40
|
+
if dt.tzinfo is None:
|
|
41
|
+
dt = dt.astimezone() # 没写时区就按本机时区
|
|
42
|
+
return dt.astimezone(timezone.utc).isoformat(timespec="seconds")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _short_code(code: str | None) -> str:
|
|
46
|
+
"""'FIT2109 S2 2026 Malaysia ' -> 'FIT2109','FIT3162/FIT3164 MUM…' -> 'FIT3162/FIT3164'。"""
|
|
47
|
+
return (code or "").split()[0] if code else ""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def courses(conn: sqlite3.Connection) -> list[dict]:
|
|
51
|
+
rows = conn.execute("""
|
|
52
|
+
SELECT c.id, c.code, c.name, c.tracked,
|
|
53
|
+
(SELECT COUNT(*) FROM threads t WHERE t.course_id = c.id) AS threads,
|
|
54
|
+
(SELECT MAX(created_at) FROM threads t WHERE t.course_id = c.id) AS latest
|
|
55
|
+
FROM courses c ORDER BY c.tracked DESC, c.id""").fetchall()
|
|
56
|
+
return [{"id": r["id"], "code": _short_code(r["code"]), "name": r["name"],
|
|
57
|
+
"tracked": bool(r["tracked"]), "threads": r["threads"], "latest": r["latest"]} for r in rows]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def resolve_course(conn: sqlite3.Connection, ref) -> dict:
|
|
61
|
+
"""课程 id、课号(FIT2102)或课号/课名的一部分(3164、paradigms)。"""
|
|
62
|
+
ref_s = str(ref).strip()
|
|
63
|
+
rows = [dict(r) for r in conn.execute("SELECT id, code, name, tracked FROM courses")]
|
|
64
|
+
if ref_s.isdigit():
|
|
65
|
+
for r in rows:
|
|
66
|
+
if str(r["id"]) == ref_s:
|
|
67
|
+
return r
|
|
68
|
+
low = ref_s.lower()
|
|
69
|
+
exact = [r for r in rows if low in [p.lower() for p in re.split(r"[\s/]+", r["code"] or "") if p]]
|
|
70
|
+
fuzzy = [r for r in rows if low in (r["code"] or "").lower() or low in (r["name"] or "").lower()]
|
|
71
|
+
for group in (exact, fuzzy):
|
|
72
|
+
if group:
|
|
73
|
+
group.sort(key=lambda r: (not r["tracked"], -r["id"]))
|
|
74
|
+
return group[0]
|
|
75
|
+
known = "、".join(_short_code(r["code"]) or str(r["id"]) for r in rows) or "(库里还没有课程)"
|
|
76
|
+
raise NotFound(f"没有匹配 “{ref}” 的课程。已有:{known}")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def resolve_thread(conn: sqlite3.Connection, ref) -> int:
|
|
80
|
+
"""帖子 id、'FIT2102#42'(课号+帖子编号)、'#42'(只有一门课时)或 Ed 帖子链接。"""
|
|
81
|
+
ref_s = str(ref).strip()
|
|
82
|
+
m = re.search(r"/discussion/(\d+)", ref_s)
|
|
83
|
+
if m:
|
|
84
|
+
ref_s = m.group(1)
|
|
85
|
+
if ref_s.isdigit():
|
|
86
|
+
row = conn.execute("SELECT id FROM threads WHERE id=?", (int(ref_s),)).fetchone()
|
|
87
|
+
if row:
|
|
88
|
+
return row["id"]
|
|
89
|
+
raise NotFound(f"库里没有帖子 {ref_s}(可能还没 sync 到,或者不在跟踪的课里)")
|
|
90
|
+
m = re.fullmatch(r"(.*?)#(\d+)", ref_s)
|
|
91
|
+
if not m:
|
|
92
|
+
raise NotFound(f"看不懂的帖子 “{ref}”:用帖子 id、FIT2102#42 或 Ed 链接")
|
|
93
|
+
course_ref, number = m.group(1).strip(), int(m.group(2))
|
|
94
|
+
if course_ref:
|
|
95
|
+
cids = [resolve_course(conn, course_ref)["id"]]
|
|
96
|
+
else:
|
|
97
|
+
cids = [r["id"] for r in conn.execute("SELECT id FROM courses WHERE tracked=1")]
|
|
98
|
+
rows = conn.execute(f"SELECT id FROM threads WHERE number=? AND course_id IN ({','.join('?' * len(cids))})",
|
|
99
|
+
(number, *cids)).fetchall()
|
|
100
|
+
if len(rows) == 1:
|
|
101
|
+
return rows[0]["id"]
|
|
102
|
+
if not rows:
|
|
103
|
+
raise NotFound(f"没有帖子 {ref_s}")
|
|
104
|
+
raise NotFound(f"“#{number}” 在好几门课里都有,写成 课号#{number}")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# --------------------------------------------------------------------------
|
|
108
|
+
# 列表 / 搜索
|
|
109
|
+
# --------------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
_LIST_COLS = """
|
|
112
|
+
t.id, t.course_id, c.code AS course_code, t.number, t.type, t.title, t.category, t.subcategory,
|
|
113
|
+
t.author, t.is_pinned, t.is_private, t.is_answered, t.is_staff_answered, t.reply_count,
|
|
114
|
+
t.vote_count, t.created_at, t.updated_at, t.url,
|
|
115
|
+
u.is_starred, u.is_watched, u.is_seen, u.new_reply_count, u.accepted_id, u.is_mine
|
|
116
|
+
"""
|
|
117
|
+
_LIST_FROM = """
|
|
118
|
+
FROM threads t JOIN courses c ON c.id = t.course_id
|
|
119
|
+
LEFT JOIN thread_state u ON u.thread_id = t.id
|
|
120
|
+
"""
|
|
121
|
+
# threads 命令的筛选开关 → SQL(都依赖 thread_state,同步过一次才有)
|
|
122
|
+
STATE_FILTERS = {
|
|
123
|
+
"starred": "u.is_starred = 1",
|
|
124
|
+
"watching": "u.is_watched = 1",
|
|
125
|
+
"unseen": "u.is_seen = 0",
|
|
126
|
+
"mine": "u.is_mine = 1",
|
|
127
|
+
"unread_replies": "u.new_reply_count > 0",
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _thread_row(r) -> dict:
|
|
132
|
+
d = dict(r)
|
|
133
|
+
d["course"] = _short_code(d.pop("course_code"))
|
|
134
|
+
d["ref"] = f"{d['course']}#{d['number']}"
|
|
135
|
+
for k in ("is_pinned", "is_private", "is_answered", "is_staff_answered",
|
|
136
|
+
"is_starred", "is_watched", "is_seen", "is_mine"):
|
|
137
|
+
d[k] = bool(d[k])
|
|
138
|
+
d["new_reply_count"] = d["new_reply_count"] or 0
|
|
139
|
+
return d
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _filters(conn, course=None, since=None, category=None, type_=None, unanswered=False, state=()):
|
|
143
|
+
where, params = [STATE_FILTERS[k] for k in state], []
|
|
144
|
+
if course:
|
|
145
|
+
where.append("t.course_id = ?")
|
|
146
|
+
params.append(resolve_course(conn, course)["id"])
|
|
147
|
+
else:
|
|
148
|
+
where.append("c.tracked = 1")
|
|
149
|
+
if since:
|
|
150
|
+
where.append("julianday(t.created_at) >= julianday(?)") # Ed 的时间带 +10:00,别按字符串比
|
|
151
|
+
params.append(parse_since(since))
|
|
152
|
+
if category:
|
|
153
|
+
where.append("(LOWER(t.category) LIKE ? OR LOWER(t.subcategory) LIKE ?)")
|
|
154
|
+
params += [f"%{category.lower()}%"] * 2
|
|
155
|
+
if type_:
|
|
156
|
+
where.append("t.type = ?")
|
|
157
|
+
params.append(type_)
|
|
158
|
+
if unanswered:
|
|
159
|
+
where.append("t.type = 'question' AND t.is_answered = 0 AND t.is_staff_answered = 0")
|
|
160
|
+
return where, params
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def list_threads(conn, course=None, since=None, category=None, type_=None,
|
|
164
|
+
unanswered=False, limit=20, offset=0, state=()) -> list[dict]:
|
|
165
|
+
"""state:STATE_FILTERS 里的键,比如 ("starred",)、("mine", "unread_replies"),全部满足。"""
|
|
166
|
+
where, params = _filters(conn, course, since, category, type_, unanswered, state)
|
|
167
|
+
rows = conn.execute(
|
|
168
|
+
f"SELECT {_LIST_COLS} {_LIST_FROM} WHERE {' AND '.join(where)} "
|
|
169
|
+
"ORDER BY julianday(t.created_at) DESC LIMIT ? OFFSET ?", (*params, int(limit), int(offset))).fetchall()
|
|
170
|
+
return [_thread_row(r) for r in rows]
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _snippet(text: str, words: list[str], width: int = 160) -> str:
|
|
174
|
+
low = text.lower()
|
|
175
|
+
hits = [low.find(w) for w in words if low.find(w) >= 0]
|
|
176
|
+
start = max(0, min(hits) - width // 3) if hits else 0
|
|
177
|
+
part = re.sub(r"\s+", " ", text[start:start + width]).strip()
|
|
178
|
+
return ("…" if start else "") + part + ("…" if start + width < len(text) else "")
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def search(conn, query: str, course=None, since=None, category=None, type_=None,
|
|
182
|
+
limit=20) -> list[dict]:
|
|
183
|
+
"""每个词都要出现(不分大小写),范围是标题、正文和全部回复。按最新排序。"""
|
|
184
|
+
words = [w.lower() for w in query.split() if w.strip()]
|
|
185
|
+
if not words:
|
|
186
|
+
raise ValueError("搜索词是空的")
|
|
187
|
+
where, params = _filters(conn, course, since, category, type_)
|
|
188
|
+
haystack = ("LOWER(COALESCE(t.title,'') || ' ' || COALESCE(t.body,'') || ' ' || "
|
|
189
|
+
"COALESCE((SELECT GROUP_CONCAT(r.text, ' ') FROM replies r WHERE r.thread_id = t.id), ''))")
|
|
190
|
+
for w in words:
|
|
191
|
+
where.append(f"INSTR({haystack}, ?) > 0")
|
|
192
|
+
params.append(w)
|
|
193
|
+
rows = conn.execute(
|
|
194
|
+
f"SELECT {_LIST_COLS}, t.body {_LIST_FROM} WHERE {' AND '.join(where)} "
|
|
195
|
+
"ORDER BY julianday(t.created_at) DESC LIMIT ?", (*params, int(limit))).fetchall()
|
|
196
|
+
out = []
|
|
197
|
+
for r in rows:
|
|
198
|
+
d = _thread_row(r)
|
|
199
|
+
body = d.pop("body") or ""
|
|
200
|
+
if not any(w in (d["title"] or "").lower() + body.lower() for w in words):
|
|
201
|
+
reps = conn.execute("SELECT text FROM replies WHERE thread_id=? ORDER BY created_at", (d["id"],))
|
|
202
|
+
body = next((x["text"] for x in reps if any(w in (x["text"] or "").lower() for w in words)), body)
|
|
203
|
+
d["match_in"] = "reply"
|
|
204
|
+
d["snippet"] = _snippet(body, words)
|
|
205
|
+
out.append(d)
|
|
206
|
+
return out
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
# --------------------------------------------------------------------------
|
|
210
|
+
# 单帖
|
|
211
|
+
# --------------------------------------------------------------------------
|
|
212
|
+
|
|
213
|
+
def get_thread(conn, ref) -> dict:
|
|
214
|
+
tid = resolve_thread(conn, ref)
|
|
215
|
+
r = conn.execute(f"SELECT {_LIST_COLS}, t.body, t.last_fetched {_LIST_FROM} WHERE t.id=?", (tid,)).fetchone()
|
|
216
|
+
thread = _thread_row(r)
|
|
217
|
+
atts = [dict(a) for a in conn.execute(
|
|
218
|
+
"SELECT reply_id, kind, name, url FROM attachments WHERE thread_id=? ORDER BY reply_id, rowid", (tid,))]
|
|
219
|
+
thread["attachments"] = [{k: a[k] for k in ("kind", "name", "url")} for a in atts if a["reply_id"] == 0]
|
|
220
|
+
thread["replies"] = []
|
|
221
|
+
for x in conn.execute("SELECT * FROM replies WHERE thread_id=? ORDER BY created_at, id", (tid,)):
|
|
222
|
+
thread["replies"].append({
|
|
223
|
+
"id": x["id"], "parent_id": x["parent_id"], "kind": x["kind"], "depth": x["depth"],
|
|
224
|
+
"author": x["author"], "is_staff": bool(x["is_staff"]), "is_endorsed": bool(x["is_endorsed"]),
|
|
225
|
+
"vote_count": x["vote_count"], "created_at": x["created_at"], "text": x["text"],
|
|
226
|
+
"attachments": [{k: a[k] for k in ("kind", "name", "url")} for a in atts if a["reply_id"] == x["id"]],
|
|
227
|
+
})
|
|
228
|
+
thread["replies"] = _nest_order(thread["replies"])
|
|
229
|
+
return thread
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _nest_order(replies: list[dict]) -> list[dict]:
|
|
233
|
+
"""按对话顺序排:每条回复后面紧跟它的子回复(深度优先),同级按时间。"""
|
|
234
|
+
children: dict = {}
|
|
235
|
+
for r in replies:
|
|
236
|
+
children.setdefault(r["parent_id"], []).append(r)
|
|
237
|
+
ids = {r["id"] for r in replies}
|
|
238
|
+
out: list[dict] = []
|
|
239
|
+
|
|
240
|
+
def walk(pid):
|
|
241
|
+
for r in children.get(pid, []):
|
|
242
|
+
out.append(r)
|
|
243
|
+
walk(r["id"])
|
|
244
|
+
|
|
245
|
+
walk(None)
|
|
246
|
+
for r in replies: # 父回复被删了的孤儿,放到最后
|
|
247
|
+
if r["parent_id"] is not None and r["parent_id"] not in ids and r not in out:
|
|
248
|
+
out.append(r)
|
|
249
|
+
return out
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def following(conn, course=None, limit=30) -> list[dict]:
|
|
253
|
+
"""Ed 上有我没看过的新回复的帖子(Ed 自己记的已读位置,跟浏览器里看到的数字一致)。
|
|
254
|
+
|
|
255
|
+
我发的、关注的、收藏的排前面;每帖带上最后那几条新回复。
|
|
256
|
+
new_reply_count 是 Ed 对“我看过的帖子”算的,没点开过的帖子不在这里(用 unseen)。
|
|
257
|
+
"""
|
|
258
|
+
rows = list_threads(conn, course, limit=500, state=("unread_replies",))
|
|
259
|
+
rows.sort(key=lambda t: (not (t["is_mine"] or t["is_watched"] or t["is_starred"]),
|
|
260
|
+
-_jd(t["updated_at"])))
|
|
261
|
+
for t in rows[:limit]:
|
|
262
|
+
reps = conn.execute("SELECT author, is_staff, kind, created_at, text FROM replies WHERE thread_id=? "
|
|
263
|
+
"ORDER BY julianday(created_at) DESC LIMIT ?", (t["id"], t["new_reply_count"])).fetchall()
|
|
264
|
+
t["new_replies"] = [{"author": x["author"], "is_staff": bool(x["is_staff"]), "kind": x["kind"],
|
|
265
|
+
"created_at": x["created_at"], "text": x["text"]} for x in reversed(reps)]
|
|
266
|
+
return rows[:limit]
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _jd(iso: str | None) -> float:
|
|
270
|
+
try:
|
|
271
|
+
return datetime.fromisoformat(iso).timestamp() if iso else 0.0
|
|
272
|
+
except ValueError:
|
|
273
|
+
return 0.0
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def thread_markdown(t: dict) -> str:
|
|
277
|
+
flags = [f for f, on in (("置顶", t["is_pinned"]), ("私密", t["is_private"]),
|
|
278
|
+
("老师已答", t["is_staff_answered"]), ("★ 已收藏", t.get("is_starred")),
|
|
279
|
+
("关注中", t.get("is_watched")), ("我发的", t.get("is_mine"))) if on]
|
|
280
|
+
lines = [f"# {t['title']}",
|
|
281
|
+
f"{t['ref']} · {t['type']} · {t['category'] or ''}"
|
|
282
|
+
+ (f" / {t['subcategory']}" if t.get("subcategory") else "")
|
|
283
|
+
+ f" · {t['author']} · {(t['created_at'] or '')[:16].replace('T', ' ')}"
|
|
284
|
+
+ (f" · {','.join(flags)}" if flags else ""),
|
|
285
|
+
t["url"], ""]
|
|
286
|
+
lines.append(t.get("body") or "(无正文)")
|
|
287
|
+
if t["replies"]:
|
|
288
|
+
lines += ["", f"## 回复({len(t['replies'])})"]
|
|
289
|
+
for r in t["replies"]:
|
|
290
|
+
pad = " " * (r["depth"] or 0)
|
|
291
|
+
tag = ("✔ 已采纳 " if t.get("accepted_id") and r["id"] == t["accepted_id"] else "") \
|
|
292
|
+
+ ("✅ " if r["is_endorsed"] else "")
|
|
293
|
+
lines += ["", f"{pad}**{tag}{r['author']}** · {r['kind']} · {(r['created_at'] or '')[:16].replace('T', ' ')}"]
|
|
294
|
+
lines += [pad + ln if ln else "" for ln in (r["text"] or "").split("\n")]
|
|
295
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
# --------------------------------------------------------------------------
|
|
299
|
+
# 新动态(带游标)
|
|
300
|
+
# --------------------------------------------------------------------------
|
|
301
|
+
|
|
302
|
+
def _cursor_file():
|
|
303
|
+
return edlib.DATA_DIR / "cursors.json"
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def read_cursor(name: str) -> str | None:
|
|
307
|
+
try:
|
|
308
|
+
return json.loads(_cursor_file().read_text(encoding="utf-8")).get(name)
|
|
309
|
+
except (OSError, ValueError):
|
|
310
|
+
return None
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def write_cursor(name: str, value: str) -> None:
|
|
314
|
+
path = _cursor_file()
|
|
315
|
+
try:
|
|
316
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
317
|
+
except (OSError, ValueError):
|
|
318
|
+
data = {}
|
|
319
|
+
data[name] = value
|
|
320
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
321
|
+
tmp = path.with_suffix(".tmp")
|
|
322
|
+
tmp.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
|
323
|
+
tmp.replace(path)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def new_activity(conn, since=None, course=None, cursor: str | None = "cli",
|
|
327
|
+
advance: bool = True, limit: int = 50) -> dict:
|
|
328
|
+
"""上次看过之后的新帖和老帖的新回复(按我们抓到的时间 first_seen 算)。
|
|
329
|
+
|
|
330
|
+
since 不给就用游标(第一次默认 3 天);advance=True 时把游标推到现在。
|
|
331
|
+
"""
|
|
332
|
+
now = edlib.now_iso()
|
|
333
|
+
start = parse_since(since) if since else (read_cursor(cursor) if cursor else None)
|
|
334
|
+
start = start or parse_since("3d")
|
|
335
|
+
course_id = resolve_course(conn, course)["id"] if course else None
|
|
336
|
+
cond, params = ("t.course_id = ?", [course_id]) if course_id else ("c.tracked = 1", [])
|
|
337
|
+
|
|
338
|
+
new_threads = [_thread_row(r) for r in conn.execute(
|
|
339
|
+
f"SELECT {_LIST_COLS} {_LIST_FROM} WHERE {cond} AND t.first_seen > ? "
|
|
340
|
+
"ORDER BY julianday(t.created_at) DESC LIMIT ?", (*params, start, int(limit)))]
|
|
341
|
+
fresh_ids = {t["id"] for t in new_threads}
|
|
342
|
+
|
|
343
|
+
replied = []
|
|
344
|
+
for r in conn.execute(
|
|
345
|
+
f"SELECT {_LIST_COLS} {_LIST_FROM} WHERE {cond} AND t.id IN "
|
|
346
|
+
"(SELECT thread_id FROM replies WHERE first_seen > ?) ORDER BY julianday(t.updated_at) DESC LIMIT ?",
|
|
347
|
+
(*params, start, int(limit))):
|
|
348
|
+
if r["id"] in fresh_ids:
|
|
349
|
+
continue
|
|
350
|
+
d = _thread_row(r)
|
|
351
|
+
d["new_replies"] = [
|
|
352
|
+
{"author": x["author"], "is_staff": bool(x["is_staff"]), "kind": x["kind"],
|
|
353
|
+
"created_at": x["created_at"], "text": x["text"]}
|
|
354
|
+
for x in conn.execute("SELECT * FROM replies WHERE thread_id=? AND first_seen > ? "
|
|
355
|
+
"ORDER BY created_at", (d["id"], start))]
|
|
356
|
+
replied.append(d)
|
|
357
|
+
|
|
358
|
+
last_run = conn.execute("SELECT finished_at FROM runs ORDER BY id DESC LIMIT 1").fetchone()
|
|
359
|
+
if advance and cursor and not since:
|
|
360
|
+
write_cursor(cursor, now)
|
|
361
|
+
return {"since": start, "until": now, "last_sync": last_run["finished_at"] if last_run else None,
|
|
362
|
+
"new_threads": new_threads, "threads_with_new_replies": replied}
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
# --------------------------------------------------------------------------
|
|
366
|
+
# 课程内容(Lessons)
|
|
367
|
+
# --------------------------------------------------------------------------
|
|
368
|
+
|
|
369
|
+
def lessons(conn, course, module=None, status=None) -> list[dict]:
|
|
370
|
+
from .lessons import ensure_lessons_schema
|
|
371
|
+
ensure_lessons_schema(conn) # 老库补上 lesson_files.idx(页序)
|
|
372
|
+
try:
|
|
373
|
+
rows = conn.execute("""
|
|
374
|
+
SELECT l.*, m.name AS module FROM lessons l LEFT JOIN modules m ON m.id = l.module_id
|
|
375
|
+
WHERE l.course_id = ? ORDER BY l.module_id, l.idx, l.id""",
|
|
376
|
+
(resolve_course(conn, course)["id"],)).fetchall()
|
|
377
|
+
except sqlite3.OperationalError: # 还没跑过 lessons 同步,表不存在
|
|
378
|
+
return []
|
|
379
|
+
out = []
|
|
380
|
+
for r in rows:
|
|
381
|
+
if module and module.lower() not in (r["module"] or "").lower() and str(module) != str(r["module_id"]):
|
|
382
|
+
continue
|
|
383
|
+
if status and status != "all" and r["status"] != status:
|
|
384
|
+
continue
|
|
385
|
+
files = [dict(f) for f in conn.execute(
|
|
386
|
+
"SELECT type, title, file_url, url, local_path FROM lesson_files WHERE lesson_id=?"
|
|
387
|
+
" ORDER BY COALESCE(idx, 1e9), id",
|
|
388
|
+
(r["id"],))]
|
|
389
|
+
out.append({"id": r["id"], "module": r["module"], "title": r["title"], "type": r["type"],
|
|
390
|
+
"status": r["status"], "due_at": r["effective_due_at"] or r["due_at"],
|
|
391
|
+
"slides": r["slide_count"], "files": files,
|
|
392
|
+
"url": f"https://edstem.org/au/courses/{r['course_id']}/lessons/{r['id']}"})
|
|
393
|
+
return out
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def quizzes(conn, course, module=None, status=None) -> list[dict]:
|
|
397
|
+
"""lesson 里的测验题(题面 + 选项,没有答案,Ed 不对学生公开)。按 lesson、quiz 页、题号排。"""
|
|
398
|
+
out = []
|
|
399
|
+
for l in lessons(conn, course, module, status):
|
|
400
|
+
try:
|
|
401
|
+
rows = conn.execute("SELECT * FROM quiz_questions WHERE lesson_id=? ORDER BY slide_id, idx",
|
|
402
|
+
(l["id"],)).fetchall()
|
|
403
|
+
except sqlite3.OperationalError:
|
|
404
|
+
return []
|
|
405
|
+
if not rows:
|
|
406
|
+
continue
|
|
407
|
+
slides: dict = {}
|
|
408
|
+
for r in rows:
|
|
409
|
+
slides.setdefault(r["slide_id"], {"title": r["slide_title"], "questions": []})["questions"].append(
|
|
410
|
+
{"n": (r["idx"] or 0) + 1, "type": r["type"], "multiple": bool(r["multiple"]),
|
|
411
|
+
"question": r["question"], "options": json.loads(r["options"] or "[]")})
|
|
412
|
+
out.append({"lesson": l["title"], "module": l["module"], "status": l["status"], "url": l["url"],
|
|
413
|
+
"quizzes": list(slides.values())})
|
|
414
|
+
return out
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def quiz_markdown(rows: list[dict]) -> str:
|
|
418
|
+
if not rows:
|
|
419
|
+
return "(没有测验题;先运行 monash sync 同步一次)\n"
|
|
420
|
+
mark = {"completed": "✅", "attempted": "◐", "unattempted": "○"}
|
|
421
|
+
out = ["> 题面和选项来自 Ed Lessons。Ed 不对学生公开答案,这里只有题目。", ""]
|
|
422
|
+
for l in rows:
|
|
423
|
+
out += [f"# {mark.get(l['status'], '·')} {l['lesson']}", l["url"], ""]
|
|
424
|
+
for qz in l["quizzes"]:
|
|
425
|
+
out += [f"## {qz['title'] or '测验'}", ""]
|
|
426
|
+
for q in qz["questions"]:
|
|
427
|
+
multi = "(多选)" if q["multiple"] else ""
|
|
428
|
+
out.append(f"**{q['n']}.** {multi}{q['question']}")
|
|
429
|
+
out += [f" {chr(65 + i)}. {o}" for i, o in enumerate(q["options"])]
|
|
430
|
+
out.append("")
|
|
431
|
+
return "\n".join(out).rstrip() + "\n"
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""把 Ed 上跟踪的课同步进本地库:帖子列表 → 变了的帖子抓全文和回复。
|
|
2
|
+
|
|
3
|
+
增量靠 content_stamp:列表里每帖的 updated_at + 几个计数,变了才重抓正文。
|
|
4
|
+
第一次同步是全量,一门课几百帖要几分钟(全局限流 0.25 秒一个请求)。
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import hashlib
|
|
9
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
10
|
+
|
|
11
|
+
from . import edlib
|
|
12
|
+
from .edlib import EdAuthError, EdClient, db_connect, now_iso
|
|
13
|
+
|
|
14
|
+
# 正文存储格式的版本。改了转换规则就加一,下一次 sync 会把所有帖子重抓一遍。
|
|
15
|
+
BODY_FORMAT = "md1"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _hash(*parts) -> str:
|
|
19
|
+
return hashlib.sha256("|".join(str(p) for p in parts).encode()).hexdigest()[:16]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def content_stamp(t: dict) -> str:
|
|
23
|
+
"""帖子的变更指纹:updated_at 再加几个计数和状态位,内容真变了才算变。"""
|
|
24
|
+
parts = (t.get("updated_at"), t.get("reply_count"), t.get("unresolved_count"),
|
|
25
|
+
t.get("vote_count"), t.get("is_staff_answered"), t.get("is_answered"),
|
|
26
|
+
t.get("is_locked"), t.get("title"))
|
|
27
|
+
return _hash(BODY_FORMAT, *parts)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def short_code(code: str | None) -> str:
|
|
31
|
+
"""'FIT2109 S2 2026 Malaysia' → 'FIT2109'。"""
|
|
32
|
+
return (code or "").split()[0] if code else ""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def upsert_courses(conn, courses: list[dict]) -> None:
|
|
36
|
+
for c in courses:
|
|
37
|
+
conn.execute(
|
|
38
|
+
"INSERT INTO courses (id, code, name, year, session, status, tracked) VALUES (?,?,?,?,?,?,0) "
|
|
39
|
+
"ON CONFLICT(id) DO UPDATE SET code=excluded.code, name=excluded.name, year=excluded.year, "
|
|
40
|
+
"session=excluded.session, status=excluded.status",
|
|
41
|
+
(c["id"], c["code"], c["name"], c["year"], c["session"], c["status"]))
|
|
42
|
+
conn.commit()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def auto_track(conn, courses: list[dict]) -> list[dict]:
|
|
46
|
+
"""第一次用的时候,默认跟踪所有 active 的课(以后可以用 monash courses 改)。"""
|
|
47
|
+
active = [c for c in courses if c.get("status") == "active"]
|
|
48
|
+
for c in active:
|
|
49
|
+
conn.execute("UPDATE courses SET tracked=1 WHERE id=?", (c["id"],))
|
|
50
|
+
conn.commit()
|
|
51
|
+
return active
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def store_thread(conn, cid: int, t: dict, full: dict, stamp: str, prev=None) -> tuple[int, int]:
|
|
55
|
+
"""把一个帖子(列表项 t + 详情 full)写进库。返回 (是否新帖, 新回复数)。"""
|
|
56
|
+
th = full.get("thread") or {}
|
|
57
|
+
body = edlib.body_of(th)
|
|
58
|
+
url = f"{edlib.WEB_BASE}/{cid}/discussion/{t['id']}"
|
|
59
|
+
is_new = prev is None
|
|
60
|
+
new_replies = 0
|
|
61
|
+
conn.execute("""
|
|
62
|
+
INSERT INTO threads (id, course_id, number, type, title, category, subcategory,
|
|
63
|
+
author, user_id, is_private, is_pinned, is_staff_answered, is_student_answered,
|
|
64
|
+
is_answered, is_endorsed, reply_count, view_count, vote_count,
|
|
65
|
+
created_at, updated_at, body, url, first_seen, last_fetched, content_hash)
|
|
66
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
|
67
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
68
|
+
title=excluded.title, category=excluded.category, subcategory=excluded.subcategory,
|
|
69
|
+
is_pinned=excluded.is_pinned, is_staff_answered=excluded.is_staff_answered,
|
|
70
|
+
is_student_answered=excluded.is_student_answered, is_answered=excluded.is_answered,
|
|
71
|
+
reply_count=excluded.reply_count, view_count=excluded.view_count,
|
|
72
|
+
vote_count=excluded.vote_count, updated_at=excluded.updated_at,
|
|
73
|
+
body=excluded.body, last_fetched=excluded.last_fetched,
|
|
74
|
+
content_hash=excluded.content_hash
|
|
75
|
+
""", (t["id"], cid, t.get("number"), t.get("type"), t.get("title"),
|
|
76
|
+
t.get("category"), t.get("subcategory"), edlib.author_of(t, full.get("users") or {}), t.get("user_id"),
|
|
77
|
+
int(bool(t.get("is_private"))), int(bool(t.get("is_pinned"))),
|
|
78
|
+
int(bool(t.get("is_staff_answered"))), int(bool(t.get("is_student_answered"))),
|
|
79
|
+
int(bool(t.get("is_answered"))), int(bool(t.get("is_endorsed"))),
|
|
80
|
+
t.get("reply_count") or 0, t.get("view_count") or 0, t.get("vote_count") or 0,
|
|
81
|
+
t.get("created_at"), t.get("updated_at"), body, url,
|
|
82
|
+
now_iso(), now_iso(), stamp))
|
|
83
|
+
|
|
84
|
+
existing = {r["id"] for r in conn.execute("SELECT id FROM replies WHERE thread_id=?", (t["id"],))}
|
|
85
|
+
flat = edlib.flatten_replies(th, full.get("users") or {})
|
|
86
|
+
edlib.save_attachments(conn, t["id"], th, flat)
|
|
87
|
+
# 拿到的是整帖,库里有、这次没有的回复就是在 Ed 上被删了,别让它一直留在本地
|
|
88
|
+
gone = existing - {r["id"] for r in flat}
|
|
89
|
+
conn.executemany("DELETE FROM replies WHERE id=?", [(rid,) for rid in gone])
|
|
90
|
+
for r in flat:
|
|
91
|
+
if r["id"] not in existing:
|
|
92
|
+
new_replies += 1
|
|
93
|
+
conn.execute("""
|
|
94
|
+
INSERT INTO replies (id, thread_id, parent_id, kind, depth, user_id, author,
|
|
95
|
+
is_endorsed, vote_count, created_at, updated_at, text, first_seen, is_staff)
|
|
96
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
|
97
|
+
ON CONFLICT(id) DO UPDATE SET text=excluded.text, author=excluded.author,
|
|
98
|
+
is_endorsed=excluded.is_endorsed, vote_count=excluded.vote_count,
|
|
99
|
+
updated_at=excluded.updated_at, is_staff=excluded.is_staff
|
|
100
|
+
""", (r["id"], t["id"], r["parent_id"], r["kind"], r["depth"], r["user_id"],
|
|
101
|
+
r["author"], int(r["is_endorsed"]), r["vote_count"],
|
|
102
|
+
r["created_at"], r["updated_at"], r["text"], now_iso(),
|
|
103
|
+
int(r.get("is_staff", False))))
|
|
104
|
+
return int(is_new), new_replies
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def my_user_id(client: EdClient) -> int | None:
|
|
108
|
+
"""自己的 Ed user id(判断“我发的帖”用),第一次问 /user 后记下来。"""
|
|
109
|
+
cfg = edlib.load_config()
|
|
110
|
+
if cfg.get("user_id"):
|
|
111
|
+
return cfg["user_id"]
|
|
112
|
+
try:
|
|
113
|
+
uid = (client.me().get("user") or {}).get("id")
|
|
114
|
+
except Exception: # noqa: BLE001
|
|
115
|
+
return None
|
|
116
|
+
if uid:
|
|
117
|
+
edlib.save_config({**cfg, "user_id": uid})
|
|
118
|
+
return uid
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def sync(course_ids: list[int] | None = None, full: bool = False, workers: int = 4, log=print,
|
|
122
|
+
client: EdClient | None = None) -> dict:
|
|
123
|
+
"""增量同步跟踪的课(或指定的课)。返回 {new_threads, new_replies}。"""
|
|
124
|
+
client = client or EdClient()
|
|
125
|
+
conn = db_connect()
|
|
126
|
+
courses = client.courses()
|
|
127
|
+
upsert_courses(conn, courses)
|
|
128
|
+
tracked = [r["id"] for r in conn.execute("SELECT id FROM courses WHERE tracked=1")]
|
|
129
|
+
if not tracked and not course_ids:
|
|
130
|
+
tracked = [c["id"] for c in auto_track(conn, courses)]
|
|
131
|
+
course_ids = course_ids or tracked
|
|
132
|
+
|
|
133
|
+
started = now_iso()
|
|
134
|
+
total_threads = total_replies = 0
|
|
135
|
+
me = my_user_id(client)
|
|
136
|
+
for cid in course_ids:
|
|
137
|
+
row = conn.execute("SELECT code FROM courses WHERE id=?", (cid,)).fetchone()
|
|
138
|
+
label = short_code(row["code"]) if row else str(cid)
|
|
139
|
+
listing = client.threads(cid)
|
|
140
|
+
edlib.save_thread_state(conn, listing, me)
|
|
141
|
+
known = {r["id"]: r for r in conn.execute(
|
|
142
|
+
"SELECT id, content_hash FROM threads WHERE course_id=?", (cid,))}
|
|
143
|
+
to_fetch = [(t, content_stamp(t)) for t in listing
|
|
144
|
+
if full or t["id"] not in known or known[t["id"]]["content_hash"] != content_stamp(t)]
|
|
145
|
+
log(f"Ed {label}:{len(listing)} 帖,{len(to_fetch)} 帖要抓")
|
|
146
|
+
|
|
147
|
+
def grab(item):
|
|
148
|
+
t, stamp = item
|
|
149
|
+
try:
|
|
150
|
+
return t, stamp, client.thread(t["id"]), None
|
|
151
|
+
except Exception as e: # noqa: BLE001
|
|
152
|
+
return t, stamp, None, e
|
|
153
|
+
|
|
154
|
+
# 网络等待是大头;全局限流在 EdClient 里,并发只是把等待重叠起来,不会加快请求速率
|
|
155
|
+
with ThreadPoolExecutor(max_workers=workers) as pool:
|
|
156
|
+
for i, (t, stamp, data, err) in enumerate(pool.map(grab, to_fetch), 1):
|
|
157
|
+
if err is not None:
|
|
158
|
+
if isinstance(err, EdAuthError):
|
|
159
|
+
raise err
|
|
160
|
+
log(f" ! 帖子 {t['id']} 抓取失败: {err}")
|
|
161
|
+
continue
|
|
162
|
+
is_new, n_rep = store_thread(conn, cid, t, data, stamp, known.get(t["id"]))
|
|
163
|
+
total_threads += is_new
|
|
164
|
+
total_replies += n_rep
|
|
165
|
+
if i % 20 == 0:
|
|
166
|
+
conn.commit()
|
|
167
|
+
log(f" … {i}/{len(to_fetch)}")
|
|
168
|
+
conn.commit()
|
|
169
|
+
|
|
170
|
+
conn.execute("INSERT INTO runs (started_at, finished_at, new_threads, new_replies, note) VALUES (?,?,?,?,?)",
|
|
171
|
+
(started, now_iso(), total_threads, total_replies, "full" if full else "incremental"))
|
|
172
|
+
conn.commit()
|
|
173
|
+
conn.close()
|
|
174
|
+
return {"new_threads": total_threads, "new_replies": total_replies}
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def refresh_thread(conn, thread_id: int) -> None:
|
|
178
|
+
"""从 Ed 重抓一个帖子写回库(ed_thread live=true 用)。"""
|
|
179
|
+
full = EdClient().thread(thread_id)
|
|
180
|
+
th = full.get("thread") or {}
|
|
181
|
+
prev = conn.execute("SELECT content_hash FROM threads WHERE id=?", (thread_id,)).fetchone()
|
|
182
|
+
store_thread(conn, th["course_id"], th, full, content_stamp(th), prev)
|
|
183
|
+
conn.commit()
|