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,551 @@
|
|
|
1
|
+
"""Ed Discussion 抓取库:API 客户端 + 内容解析 + SQLite 存储。"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import html
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import sqlite3
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
11
|
+
import urllib.error
|
|
12
|
+
import urllib.parse
|
|
13
|
+
import urllib.request
|
|
14
|
+
from datetime import datetime, timezone
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from .paths import DATA_DIR, ED_DB, ED_TOKEN_FILE, write_secret
|
|
18
|
+
|
|
19
|
+
API_BASE = "https://edstem.org/api"
|
|
20
|
+
WEB_BASE = "https://edstem.org/au/courses"
|
|
21
|
+
TOKEN_PAGE = "https://edstem.org/au/settings/api-tokens"
|
|
22
|
+
UA = "monash-study-kit (personal study helper)" # Ed 对 Python 默认 UA 回 403
|
|
23
|
+
|
|
24
|
+
TOKEN_FILE = ED_TOKEN_FILE
|
|
25
|
+
CONFIG_FILE = DATA_DIR / "ed-config.json"
|
|
26
|
+
DB_PATH = ED_DB
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class EdAuthError(RuntimeError):
|
|
30
|
+
"""token 失效/过期。"""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class EdForbidden(RuntimeError):
|
|
34
|
+
"""token 没问题,但这个东西看不到了(帖子被删、被改成私密等)。Ed 回 403。"""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# --------------------------------------------------------------------------
|
|
38
|
+
# token / config
|
|
39
|
+
# --------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
def load_token() -> str:
|
|
42
|
+
tok = os.environ.get("ED_TOKEN")
|
|
43
|
+
if tok:
|
|
44
|
+
return tok.strip()
|
|
45
|
+
if not TOKEN_FILE.exists():
|
|
46
|
+
raise EdAuthError(f"还没设置 Ed 令牌,运行 `monash login ed`(令牌在 {TOKEN_PAGE} 创建)")
|
|
47
|
+
return TOKEN_FILE.read_text(encoding="utf-8").strip()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def save_token(token: str) -> None:
|
|
51
|
+
write_secret(TOKEN_FILE, token.strip() + "\n")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def is_api_token(token: str) -> bool:
|
|
55
|
+
"""Ed 设置页(settings/api-tokens)生成的 API 令牌不过期,形如 xxxx.yyyy(两段);
|
|
56
|
+
从 localStorage 取的是三段式 JWT,14 天过期。两种令牌走的请求头不一样。"""
|
|
57
|
+
return token.count(".") != 2
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def auth_headers(token: str) -> dict:
|
|
61
|
+
if is_api_token(token):
|
|
62
|
+
return {"authorization": f"Bearer {token}"}
|
|
63
|
+
return {"x-token": token}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def token_expiry(token: str) -> datetime | None:
|
|
67
|
+
"""读浏览器 JWT 的 exp(不校验签名,只是为了提前提醒续期)。API 令牌不过期,返回 None。"""
|
|
68
|
+
try:
|
|
69
|
+
import base64
|
|
70
|
+
payload = token.split(".")[1]
|
|
71
|
+
payload += "=" * (-len(payload) % 4)
|
|
72
|
+
return datetime.fromtimestamp(json.loads(base64.urlsafe_b64decode(payload))["exp"], tz=timezone.utc)
|
|
73
|
+
except Exception: # noqa: BLE001
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def load_config() -> dict:
|
|
78
|
+
try:
|
|
79
|
+
return json.loads(CONFIG_FILE.read_text(encoding="utf-8"))
|
|
80
|
+
except (OSError, ValueError):
|
|
81
|
+
return {}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def save_config(cfg: dict) -> None:
|
|
85
|
+
CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
86
|
+
CONFIG_FILE.write_text(json.dumps(cfg, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# --------------------------------------------------------------------------
|
|
90
|
+
# API 客户端
|
|
91
|
+
# --------------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
RETRY_AFTER_CAP = 120
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def retry_delay(retry_after: str | None, attempt: int) -> float:
|
|
97
|
+
"""限流/临时故障后等多久:Ed 给了 Retry-After(秒数或 HTTP 日期)就按它来,
|
|
98
|
+
比指数退避长也听它的(最多等 RETRY_AFTER_CAP 秒);没给就 1.5s、3s、6s……"""
|
|
99
|
+
backoff = 2 ** attempt * 1.5
|
|
100
|
+
if not retry_after:
|
|
101
|
+
return backoff
|
|
102
|
+
try:
|
|
103
|
+
wait = float(retry_after)
|
|
104
|
+
except ValueError:
|
|
105
|
+
try:
|
|
106
|
+
from email.utils import parsedate_to_datetime
|
|
107
|
+
wait = (parsedate_to_datetime(retry_after) - datetime.now(timezone.utc)).total_seconds()
|
|
108
|
+
except (TypeError, ValueError):
|
|
109
|
+
return backoff
|
|
110
|
+
return min(max(wait, backoff), RETRY_AFTER_CAP)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class EdClient:
|
|
114
|
+
def __init__(self, token: str | None = None, delay: float = 0.25):
|
|
115
|
+
self.token = token or load_token()
|
|
116
|
+
self.delay = delay # 全局最小请求间隔,别把 Ed 打疼
|
|
117
|
+
self._last = 0.0
|
|
118
|
+
self._lock = threading.Lock() # 并行抓取时,限流必须是全局的
|
|
119
|
+
|
|
120
|
+
def _throttle(self) -> None:
|
|
121
|
+
with self._lock:
|
|
122
|
+
gap = self.delay - (time.time() - self._last)
|
|
123
|
+
if gap > 0:
|
|
124
|
+
time.sleep(gap)
|
|
125
|
+
self._last = time.time()
|
|
126
|
+
|
|
127
|
+
def _request(self, url: str, data: bytes | None = None) -> urllib.request.Request:
|
|
128
|
+
req = urllib.request.Request(url, data=data, method="POST" if data is not None else "GET",
|
|
129
|
+
headers={"accept": "application/json", "user-agent": UA})
|
|
130
|
+
# 令牌放在“不跟随跳转”的头里:Ed 万一把请求跳到别的网站,urllib 不会把令牌一起带过去
|
|
131
|
+
for k, v in auth_headers(self.token).items():
|
|
132
|
+
req.add_unredirected_header(k, v)
|
|
133
|
+
return req
|
|
134
|
+
|
|
135
|
+
def get(self, path: str, params: dict | None = None, retries: int = 4):
|
|
136
|
+
url = f"{API_BASE}{path}"
|
|
137
|
+
if params:
|
|
138
|
+
url += "?" + urllib.parse.urlencode(params)
|
|
139
|
+
req = self._request(url)
|
|
140
|
+
for attempt in range(retries):
|
|
141
|
+
self._throttle()
|
|
142
|
+
try:
|
|
143
|
+
with urllib.request.urlopen(req, timeout=45) as resp:
|
|
144
|
+
return json.loads(resp.read().decode("utf-8"))
|
|
145
|
+
except urllib.error.HTTPError as e:
|
|
146
|
+
if e.code == 401:
|
|
147
|
+
raise EdAuthError(
|
|
148
|
+
f"Ed 返回 401:令牌失效({path})。在 Ed 设置页新建一个令牌,再运行 `monash login ed`"
|
|
149
|
+
) from e
|
|
150
|
+
if e.code == 403:
|
|
151
|
+
raise EdForbidden(f"Ed 返回 403:没有权限看 {path}(被删除、改成私密,或不在这门课里)") from e
|
|
152
|
+
if e.code in (429, 500, 502, 503, 504) and attempt < retries - 1:
|
|
153
|
+
time.sleep(retry_delay(e.headers.get("Retry-After") if e.headers else None, attempt))
|
|
154
|
+
continue
|
|
155
|
+
raise
|
|
156
|
+
except (urllib.error.URLError, TimeoutError):
|
|
157
|
+
if attempt < retries - 1:
|
|
158
|
+
time.sleep(2 ** attempt * 1.5)
|
|
159
|
+
continue
|
|
160
|
+
raise
|
|
161
|
+
raise RuntimeError(f"请求失败:{path}")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def post(self, path: str):
|
|
165
|
+
req = self._request(f"{API_BASE}{path}", data=b"")
|
|
166
|
+
try:
|
|
167
|
+
with urllib.request.urlopen(req, timeout=45) as resp:
|
|
168
|
+
return json.loads(resp.read().decode("utf-8"))
|
|
169
|
+
except urllib.error.HTTPError as e:
|
|
170
|
+
if e.code in (401, 403):
|
|
171
|
+
raise EdAuthError(f"Ed 返回 {e.code}:令牌失效({path})") from e
|
|
172
|
+
raise
|
|
173
|
+
|
|
174
|
+
def renew(self) -> datetime | None:
|
|
175
|
+
"""浏览器里取的 JWT 固定 14 天过期,POST /api/renew_token 可以随时换一张新的并写回令牌文件。
|
|
176
|
+
|
|
177
|
+
定时任务至少每 14 天跑一次就永远不会过期。API 令牌不过期,不用也不能续,返回 None。
|
|
178
|
+
"""
|
|
179
|
+
if is_api_token(self.token):
|
|
180
|
+
return None
|
|
181
|
+
tok = (self.post("/renew_token") or {}).get("token")
|
|
182
|
+
if not tok:
|
|
183
|
+
raise RuntimeError("renew_token 没有返回 token 字段")
|
|
184
|
+
save_token(tok)
|
|
185
|
+
self.token = tok
|
|
186
|
+
return token_expiry(tok)
|
|
187
|
+
|
|
188
|
+
def me(self) -> dict:
|
|
189
|
+
return self.get("/user")
|
|
190
|
+
|
|
191
|
+
def courses(self) -> list[dict]:
|
|
192
|
+
data = self.me()
|
|
193
|
+
out = []
|
|
194
|
+
for entry in data.get("courses", []):
|
|
195
|
+
c = entry.get("course", {})
|
|
196
|
+
out.append({
|
|
197
|
+
"id": c.get("id"),
|
|
198
|
+
"code": c.get("code"),
|
|
199
|
+
"name": c.get("name"),
|
|
200
|
+
"year": c.get("year"),
|
|
201
|
+
"session": c.get("session"),
|
|
202
|
+
"status": c.get("status"),
|
|
203
|
+
"role": entry.get("role"),
|
|
204
|
+
})
|
|
205
|
+
return out
|
|
206
|
+
|
|
207
|
+
def threads(self, course_id: int, limit: int = 100) -> list[dict]:
|
|
208
|
+
"""拉一门课的全部帖子列表(分页直到拉空)。"""
|
|
209
|
+
all_threads, seen, offset = [], set(), 0
|
|
210
|
+
while True:
|
|
211
|
+
data = self.get(f"/courses/{course_id}/threads",
|
|
212
|
+
{"limit": limit, "offset": offset, "sort": "new"})
|
|
213
|
+
batch = data.get("threads", [])
|
|
214
|
+
if not batch:
|
|
215
|
+
break
|
|
216
|
+
fresh = [t for t in batch if t["id"] not in seen]
|
|
217
|
+
for t in fresh:
|
|
218
|
+
seen.add(t["id"])
|
|
219
|
+
all_threads.extend(fresh)
|
|
220
|
+
if len(batch) < limit:
|
|
221
|
+
break
|
|
222
|
+
offset += limit
|
|
223
|
+
if offset > 20000: # 保险丝
|
|
224
|
+
break
|
|
225
|
+
return all_threads
|
|
226
|
+
|
|
227
|
+
def thread(self, thread_id: int) -> dict:
|
|
228
|
+
"""抓单个帖子的完整内容。
|
|
229
|
+
|
|
230
|
+
千万别加 ?view=1,那个参数会把帖子的 updated_at 顶成当前时间。
|
|
231
|
+
我们拿 updated_at 当变更指纹,加了它就等于每抓一次都把指纹改一次,
|
|
232
|
+
增量同步会永远退化成全量(而且每次都把 177 个帖子的时间戳再刷一遍)。
|
|
233
|
+
不带 view=1 返回的正文、回复、users 完全一样。
|
|
234
|
+
"""
|
|
235
|
+
return self.get(f"/threads/{thread_id}")
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
# --------------------------------------------------------------------------
|
|
239
|
+
# Ed 的 XML 文档 -> Markdown
|
|
240
|
+
# --------------------------------------------------------------------------
|
|
241
|
+
#
|
|
242
|
+
# 帖子/回复有两个正文字段:`document` 是 Ed 自己渲染的纯文本,会把 <link href> 的网址、
|
|
243
|
+
# 附件、视频全扔掉;`content` 才是原始 XML。所以一律读 content 再转 Markdown。
|
|
244
|
+
|
|
245
|
+
_TAG = re.compile(r"<[^>]+>")
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _md_inline(el) -> str:
|
|
249
|
+
"""段落内部:粗体、斜体、行内代码、链接、@提及。"""
|
|
250
|
+
out = [el.text or ""]
|
|
251
|
+
for ch in el:
|
|
252
|
+
out.append(_md_node(ch, inline=True))
|
|
253
|
+
out.append(ch.tail or "")
|
|
254
|
+
return "".join(out)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _wrap(mark: str, inner: str) -> str:
|
|
258
|
+
# ** x ** 在 Markdown 里不算粗体,把空白挪到标记外面
|
|
259
|
+
core = inner.strip()
|
|
260
|
+
if not core:
|
|
261
|
+
return inner
|
|
262
|
+
lead = inner[:len(inner) - len(inner.lstrip())]
|
|
263
|
+
trail = inner[len(inner.rstrip()):]
|
|
264
|
+
return f"{lead}{mark}{core}{mark}{trail}"
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _md_list(el) -> str:
|
|
268
|
+
"""列表项里的内容(含嵌套列表)先按顶层渲染,续行统一缩进到项目符号后面。"""
|
|
269
|
+
numbered = el.get("style") == "number"
|
|
270
|
+
lines = []
|
|
271
|
+
for i, item in enumerate([c for c in el if c.tag == "list-item"], 1):
|
|
272
|
+
bullet = f"{i}." if numbered else "-"
|
|
273
|
+
body = _md_blocks(item).replace("\n\n", "\n")
|
|
274
|
+
first, *rest = body.split("\n")
|
|
275
|
+
lines.append(f"{bullet} {first}")
|
|
276
|
+
lines += [" " * (len(bullet) + 1) + r if r else r for r in rest]
|
|
277
|
+
return "\n".join(lines)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _md_node(el, inline: bool = False) -> str:
|
|
281
|
+
t = el.tag
|
|
282
|
+
if t == "bold":
|
|
283
|
+
return _wrap("**", _md_inline(el))
|
|
284
|
+
if t == "italic":
|
|
285
|
+
return _wrap("*", _md_inline(el))
|
|
286
|
+
if t == "strike":
|
|
287
|
+
return _wrap("~~", _md_inline(el))
|
|
288
|
+
if t == "underline":
|
|
289
|
+
return _md_inline(el)
|
|
290
|
+
if t == "code":
|
|
291
|
+
return f"`{''.join(el.itertext())}`"
|
|
292
|
+
if t == "link":
|
|
293
|
+
text = _md_inline(el).strip()
|
|
294
|
+
href = el.get("href") or ""
|
|
295
|
+
if not href or text == href:
|
|
296
|
+
return href or text
|
|
297
|
+
return f"[{text or href}]({href})"
|
|
298
|
+
if t == "mention":
|
|
299
|
+
return "@" + "".join(el.itertext())
|
|
300
|
+
if t == "break":
|
|
301
|
+
return "\n"
|
|
302
|
+
if t == "math":
|
|
303
|
+
return f"${''.join(el.itertext())}$"
|
|
304
|
+
if t == "image":
|
|
305
|
+
return f"})"
|
|
306
|
+
if t == "file":
|
|
307
|
+
return f"[📎 {el.get('filename') or '附件'}]({el.get('url', '')})"
|
|
308
|
+
if t == "video":
|
|
309
|
+
return f"[视频]({el.get('src', '')})"
|
|
310
|
+
if inline:
|
|
311
|
+
return _md_inline(el)
|
|
312
|
+
# ---- 块级 ----
|
|
313
|
+
if t == "paragraph":
|
|
314
|
+
return _md_inline(el)
|
|
315
|
+
if t == "heading":
|
|
316
|
+
return "#" * int(el.get("level") or 2) + " " + _md_inline(el).strip()
|
|
317
|
+
if t == "list":
|
|
318
|
+
return _md_list(el)
|
|
319
|
+
if t in ("pre", "snippet"):
|
|
320
|
+
lang = el.get("language") or ""
|
|
321
|
+
if lang in ("none", "plain"):
|
|
322
|
+
lang = ""
|
|
323
|
+
code = "".join(el.itertext()).rstrip("\n")
|
|
324
|
+
return f"```{lang}\n{code}\n```"
|
|
325
|
+
if t in ("blockquote", "callout"):
|
|
326
|
+
inner = _md_blocks(el)
|
|
327
|
+
return "\n".join("> " + l if l else ">" for l in inner.split("\n"))
|
|
328
|
+
if t == "figure":
|
|
329
|
+
return _md_blocks(el)
|
|
330
|
+
return _md_blocks(el) if len(el) else "".join(el.itertext())
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _md_blocks(el) -> str:
|
|
334
|
+
blocks = []
|
|
335
|
+
if (el.text or "").strip():
|
|
336
|
+
blocks.append(el.text.strip())
|
|
337
|
+
for ch in el:
|
|
338
|
+
md = _md_node(ch)
|
|
339
|
+
if md.strip():
|
|
340
|
+
blocks.append(md.rstrip())
|
|
341
|
+
if (ch.tail or "").strip():
|
|
342
|
+
blocks.append(ch.tail.strip())
|
|
343
|
+
return "\n\n".join(blocks)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _regex_to_text(s: str) -> str:
|
|
347
|
+
"""XML 解析失败时的兜底,至少把网址留下。"""
|
|
348
|
+
s = re.sub(r"</?(pre|snippet)[^>]*>", "\n```\n", s)
|
|
349
|
+
s = re.sub(r"</?(paragraph|heading|list-item|callout|blockquote)[^>]*>", "\n", s)
|
|
350
|
+
s = re.sub(r"<break[^>]*/?>", "\n", s)
|
|
351
|
+
s = re.sub(r'<image[^>]*src="([^"]*)"[^>]*/?>', r"\n\n", s)
|
|
352
|
+
s = re.sub(r'<file[^>]*url="([^"]*)"[^>]*filename="([^"]*)"[^>]*/?>', r"\n[📎 \2](\1)\n", s)
|
|
353
|
+
s = re.sub(r'<video[^>]*src="([^"]*)"[^>]*/?>', r"\n[视频](\1)\n", s)
|
|
354
|
+
s = re.sub(r'<link[^>]*href="([^"]*)"[^>]*>(.*?)</link>', r"[\2](\1)", s, flags=re.S)
|
|
355
|
+
s = _TAG.sub("", s)
|
|
356
|
+
return html.unescape(s)
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def doc_to_text(document: str | None) -> str:
|
|
360
|
+
"""把 Ed 的正文转成 Markdown。
|
|
361
|
+
|
|
362
|
+
传 content(<document> XML)才能保住链接和附件;传 document 纯文本就原样返回。
|
|
363
|
+
"""
|
|
364
|
+
if not document:
|
|
365
|
+
return ""
|
|
366
|
+
s = document.strip()
|
|
367
|
+
if not s.startswith("<document"):
|
|
368
|
+
return s
|
|
369
|
+
try:
|
|
370
|
+
import xml.etree.ElementTree as ET
|
|
371
|
+
root = ET.fromstring(s)
|
|
372
|
+
out = _md_blocks(root)
|
|
373
|
+
except Exception:
|
|
374
|
+
out = _regex_to_text(s)
|
|
375
|
+
out = re.sub(r"[ \t]+\n", "\n", out)
|
|
376
|
+
out = re.sub(r"\n{3,}", "\n\n", out)
|
|
377
|
+
return out.strip()
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def body_of(obj: dict) -> str:
|
|
381
|
+
"""帖子或回复的正文 Markdown:优先 content(XML),没有才退回 document 纯文本。"""
|
|
382
|
+
return doc_to_text(obj.get("content") or obj.get("document"))
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def doc_attachments(document: str | None) -> list[dict]:
|
|
386
|
+
"""正文里的附件、图片、视频:[{kind, name, url}]。"""
|
|
387
|
+
if not document or not document.lstrip().startswith("<document"):
|
|
388
|
+
return []
|
|
389
|
+
out = []
|
|
390
|
+
for m in re.finditer(r"<(file|image|video)\b([^>]*)/?>", document):
|
|
391
|
+
attrs = dict(re.findall(r'(\w+)="([^"]*)"', m.group(2)))
|
|
392
|
+
url = html.unescape(attrs.get("url") or attrs.get("src") or "")
|
|
393
|
+
if not url:
|
|
394
|
+
continue
|
|
395
|
+
kind = m.group(1)
|
|
396
|
+
name = attrs.get("filename") or url.rstrip("/").rsplit("/", 1)[-1]
|
|
397
|
+
out.append({"kind": kind, "name": html.unescape(name), "url": url})
|
|
398
|
+
return out
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def users_index(users) -> dict:
|
|
402
|
+
"""Ed 的 users 有两种形态:API 直接返回的是 list,browser_export 存的是 {id: {...}}。
|
|
403
|
+
统一成 {str(id): {...}}。"""
|
|
404
|
+
if not users:
|
|
405
|
+
return {}
|
|
406
|
+
if isinstance(users, dict):
|
|
407
|
+
return {str(k): v for k, v in users.items()}
|
|
408
|
+
return {str(u["id"]): u for u in users if isinstance(u, dict) and u.get("id") is not None}
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _lookup(obj: dict, users: dict | None) -> dict:
|
|
412
|
+
u = obj.get("user") or {}
|
|
413
|
+
if not u and users:
|
|
414
|
+
u = users.get(str(obj.get("user_id"))) or {}
|
|
415
|
+
return u
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _is_staff(obj: dict, users: dict | None) -> bool:
|
|
419
|
+
u = _lookup(obj, users)
|
|
420
|
+
return (u.get("course_role") or u.get("role")) in ("admin", "staff")
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
ROLE_LABEL = {"admin": "教师", "staff": "助教", "student": "同学"}
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def author_of(obj: dict, users=None) -> str:
|
|
427
|
+
"""回帖作者名。users 可以是 Ed 返回的 list,也可以是 {id: {...}} 映射。"""
|
|
428
|
+
if obj.get("is_anonymous"):
|
|
429
|
+
return "匿名"
|
|
430
|
+
users = users_index(users) if not isinstance(users, dict) or (
|
|
431
|
+
users and not isinstance(next(iter(users.values()), None), dict)) else users
|
|
432
|
+
u = _lookup(obj, users)
|
|
433
|
+
name = u.get("name") or "未知"
|
|
434
|
+
role = u.get("course_role") or u.get("role")
|
|
435
|
+
if role and role != "student":
|
|
436
|
+
name += f"·{ROLE_LABEL.get(role, role)}"
|
|
437
|
+
return name
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def flatten_replies(thread: dict, users=None) -> list[dict]:
|
|
441
|
+
"""把 answers/comments 的嵌套结构拍平成有序列表。"""
|
|
442
|
+
users = users_index(users)
|
|
443
|
+
out: list[dict] = []
|
|
444
|
+
|
|
445
|
+
def walk(items, kind, depth, parent_id):
|
|
446
|
+
for it in items or []:
|
|
447
|
+
if it.get("deleted_at"):
|
|
448
|
+
# 被删的回复本身不要,但它下面别人的回复还在 Ed 上显示,照样收进来
|
|
449
|
+
# (父回复不在库里,edquery._nest_order 会把它们排到最后)
|
|
450
|
+
walk(it.get("comments"), "reply", depth + 1, it.get("id"))
|
|
451
|
+
walk(it.get("replies"), "reply", depth + 1, it.get("id"))
|
|
452
|
+
continue
|
|
453
|
+
out.append({
|
|
454
|
+
"id": it.get("id"),
|
|
455
|
+
"parent_id": parent_id,
|
|
456
|
+
"kind": kind,
|
|
457
|
+
"depth": depth,
|
|
458
|
+
"user_id": it.get("user_id"),
|
|
459
|
+
"author": author_of(it, users),
|
|
460
|
+
"is_staff": _is_staff(it, users),
|
|
461
|
+
"is_endorsed": bool(it.get("is_endorsed")),
|
|
462
|
+
"is_anonymous": bool(it.get("is_anonymous")),
|
|
463
|
+
"vote_count": it.get("vote_count") or 0,
|
|
464
|
+
"created_at": it.get("created_at"),
|
|
465
|
+
"updated_at": it.get("updated_at"),
|
|
466
|
+
"text": body_of(it),
|
|
467
|
+
"attachments": doc_attachments(it.get("content")),
|
|
468
|
+
})
|
|
469
|
+
walk(it.get("comments"), "reply", depth + 1, it.get("id"))
|
|
470
|
+
walk(it.get("replies"), "reply", depth + 1, it.get("id"))
|
|
471
|
+
|
|
472
|
+
walk(thread.get("answers"), "answer", 0, None)
|
|
473
|
+
walk(thread.get("comments"), "comment", 0, None)
|
|
474
|
+
return out
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
# --------------------------------------------------------------------------
|
|
478
|
+
# SQLite
|
|
479
|
+
# --------------------------------------------------------------------------
|
|
480
|
+
|
|
481
|
+
SCHEMA = """
|
|
482
|
+
CREATE TABLE IF NOT EXISTS courses (
|
|
483
|
+
id INTEGER PRIMARY KEY, code TEXT, name TEXT, year TEXT, session TEXT,
|
|
484
|
+
status TEXT, tracked INTEGER DEFAULT 0
|
|
485
|
+
);
|
|
486
|
+
CREATE TABLE IF NOT EXISTS threads (
|
|
487
|
+
id INTEGER PRIMARY KEY, course_id INTEGER, number INTEGER, type TEXT,
|
|
488
|
+
title TEXT, category TEXT, subcategory TEXT, author TEXT, user_id INTEGER,
|
|
489
|
+
is_private INTEGER, is_pinned INTEGER, is_staff_answered INTEGER,
|
|
490
|
+
is_student_answered INTEGER, is_answered INTEGER, is_endorsed INTEGER,
|
|
491
|
+
reply_count INTEGER, view_count INTEGER, vote_count INTEGER,
|
|
492
|
+
created_at TEXT, updated_at TEXT, body TEXT, url TEXT,
|
|
493
|
+
first_seen TEXT, last_fetched TEXT, content_hash TEXT
|
|
494
|
+
);
|
|
495
|
+
CREATE TABLE IF NOT EXISTS replies (
|
|
496
|
+
id INTEGER PRIMARY KEY, thread_id INTEGER, parent_id INTEGER, kind TEXT,
|
|
497
|
+
depth INTEGER, user_id INTEGER, author TEXT, is_endorsed INTEGER,
|
|
498
|
+
vote_count INTEGER, created_at TEXT, updated_at TEXT, text TEXT,
|
|
499
|
+
first_seen TEXT, is_staff INTEGER DEFAULT 0
|
|
500
|
+
);
|
|
501
|
+
CREATE TABLE IF NOT EXISTS runs (
|
|
502
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT, started_at TEXT, finished_at TEXT,
|
|
503
|
+
new_threads INTEGER, new_replies INTEGER, note TEXT
|
|
504
|
+
);
|
|
505
|
+
CREATE TABLE IF NOT EXISTS attachments (
|
|
506
|
+
thread_id INTEGER, reply_id INTEGER DEFAULT 0, kind TEXT, name TEXT, url TEXT,
|
|
507
|
+
PRIMARY KEY (thread_id, reply_id, url)
|
|
508
|
+
);
|
|
509
|
+
-- 帖子列表里“跟我有关”的状态:收藏、关注、看过没有、有几条我没看过的回复、采纳了哪条。
|
|
510
|
+
-- 这些变了 updated_at 不会变,所以不进 content_hash,每次同步按列表整批覆盖。
|
|
511
|
+
CREATE TABLE IF NOT EXISTS thread_state (
|
|
512
|
+
thread_id INTEGER PRIMARY KEY, is_starred INTEGER, is_watched INTEGER, is_seen INTEGER,
|
|
513
|
+
new_reply_count INTEGER, accepted_id INTEGER, is_mine INTEGER, synced_at TEXT
|
|
514
|
+
);
|
|
515
|
+
CREATE INDEX IF NOT EXISTS idx_threads_course ON threads(course_id, created_at);
|
|
516
|
+
CREATE INDEX IF NOT EXISTS idx_replies_thread ON replies(thread_id, created_at);
|
|
517
|
+
"""
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def save_attachments(conn: sqlite3.Connection, thread_id: int, thread: dict, replies: list[dict]) -> None:
|
|
521
|
+
"""整帖重写附件清单(帖子正文 reply_id=0,回复里的记回复 id)。"""
|
|
522
|
+
conn.execute("DELETE FROM attachments WHERE thread_id=?", (thread_id,))
|
|
523
|
+
rows = [(thread_id, 0, a) for a in doc_attachments(thread.get("content"))]
|
|
524
|
+
rows += [(thread_id, r["id"], a) for r in replies for a in r.get("attachments") or []]
|
|
525
|
+
conn.executemany(
|
|
526
|
+
"INSERT OR IGNORE INTO attachments (thread_id, reply_id, kind, name, url) VALUES (?,?,?,?,?)",
|
|
527
|
+
[(tid, rid, a["kind"], a["name"], a["url"]) for tid, rid, a in rows])
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def save_thread_state(conn: sqlite3.Connection, listing: list[dict], me: int | None) -> None:
|
|
531
|
+
"""用帖子列表整批覆盖 thread_state。is_watched 为 null 表示没关注(Ed 只对关注的给 true)。"""
|
|
532
|
+
ts = now_iso()
|
|
533
|
+
conn.executemany(
|
|
534
|
+
"INSERT OR REPLACE INTO thread_state VALUES (?,?,?,?,?,?,?,?)",
|
|
535
|
+
[(t["id"], int(bool(t.get("is_starred"))), int(bool(t.get("is_watched"))), int(bool(t.get("is_seen"))),
|
|
536
|
+
t.get("new_reply_count") or 0, t.get("accepted_id"), int(me is not None and t.get("user_id") == me), ts)
|
|
537
|
+
for t in listing])
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def db_connect(path: Path | None = None) -> sqlite3.Connection:
|
|
541
|
+
path = path or DB_PATH
|
|
542
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
543
|
+
conn = sqlite3.connect(path, timeout=30, check_same_thread=False)
|
|
544
|
+
conn.row_factory = sqlite3.Row
|
|
545
|
+
conn.execute("PRAGMA journal_mode=WAL")
|
|
546
|
+
conn.executescript(SCHEMA)
|
|
547
|
+
return conn
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def now_iso() -> str:
|
|
551
|
+
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|