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,128 @@
1
+ """把同步下来的录像(Moodle 上的 mp4、Ed 里的 Zoom 录像)转成带时间戳的字幕稿。
2
+
3
+ monash media [--limit N] [--model small.en] [--dry-run]
4
+
5
+ 每个视频旁边生成 <原名>.transcript.md,并登记进 files 表,list_files、read_file、
6
+ 全文搜索都能看到,Claude 读字幕稿就知道老师讲了什么。
7
+
8
+ 用 faster-whisper(CTranslate2,CPU int8),要装 [media]。普通笔记本上 small.en 大约几倍实时,
9
+ 一节 2 小时的课要跑二三十分钟,所以只在手动运行 monash media 时做,不放进后台同步。
10
+ * 内容完全一样的视频只识别一次(Moodle 上同一段录像常出现在两周),另一份写一句指过去
11
+ * 给模型一段提示:课程名 + 这一周的标题,专有名词(Functor、beta reduction…)会准很多
12
+ * 没有讲话的录屏(演示动画)会写明"没有识别到讲话",不会硬编内容
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import hashlib
17
+ import os
18
+ import re
19
+ import time
20
+ from pathlib import Path
21
+
22
+ from .moodlelib import FILES_DIR, get_state, now_iso, set_state
23
+ from .recordings import fmt_ts, register, write_transcript
24
+
25
+ MEDIA = (".mp4", ".mov", ".m4a", ".mp3", ".webm", ".mkv", ".wav")
26
+ MODEL = os.environ.get("MONASH_KIT_WHISPER_MODEL", "small.en")
27
+ THREADS = int(os.environ.get("MONASH_KIT_WHISPER_THREADS", str(max(1, (os.cpu_count() or 4) // 2))))
28
+
29
+
30
+ def transcript_path(media_rel: str) -> str:
31
+ return re.sub(r"\.[^./]+$", "", media_rel) + ".transcript.md"
32
+
33
+
34
+ def file_hash(p: Path) -> str:
35
+ """大小 + 开头 16 MB 的 SHA-1:够分辨"是不是同一个文件",不用读完几百 MB。"""
36
+ h = hashlib.sha1(str(p.stat().st_size).encode())
37
+ with open(p, "rb") as f:
38
+ h.update(f.read(16 << 20))
39
+ return h.hexdigest()
40
+
41
+
42
+ def cached_hash(con, rel: str) -> str:
43
+ """file_hash 记在库里,文件大小和修改时间没变就直接用。
44
+
45
+ 每次开跑都要给所有转写过的视频算一遍哈希来认重复,视频一多就是每次读几个 GB。
46
+ """
47
+ st = (FILES_DIR / rel).stat()
48
+ key = f"hash:{rel}"
49
+ hit = get_state(con, key)
50
+ if hit and hit[:2] == [st.st_size, st.st_mtime]:
51
+ return hit[2]
52
+ h = file_hash(FILES_DIR / rel)
53
+ set_state(con, key, [st.st_size, st.st_mtime, h])
54
+ return h
55
+
56
+
57
+ def pending(con) -> list[dict]:
58
+ rows = con.execute("SELECT f.course_id, f.path, f.title, f.section, c.fullname FROM files f "
59
+ "LEFT JOIN courses c ON c.id=f.course_id WHERE f.removed_at IS NULL ORDER BY f.path").fetchall()
60
+ have = {r["path"] for r in rows}
61
+ out = []
62
+ for r in rows:
63
+ if r["path"].lower().endswith(MEDIA) and transcript_path(r["path"]) not in have \
64
+ and (FILES_DIR / r["path"]).exists():
65
+ out.append(dict(r))
66
+ return out
67
+
68
+
69
+ def prompt_for(item: dict) -> str:
70
+ """课程名 + 周目录名,比如 "FIT2102 Programming paradigms. Week 08 - Functors and Applicatives in Haskell."。"""
71
+ parts = item["path"].split("/")
72
+ week = next((p for p in parts if p.startswith("Week ")), "")
73
+ return f"{item.get('fullname') or parts[0]}. {week}.".replace("..", ".")
74
+
75
+
76
+ def run(con, *, model_name: str = MODEL, limit: int = 0, match: str | None = None, dry_run: bool = False,
77
+ log=print) -> int:
78
+ """转写还没有字幕稿的视频,返回处理了几个。"""
79
+ todo = pending(con)
80
+ if match:
81
+ todo = [t for t in todo if match.lower() in t["path"].lower()]
82
+ if limit:
83
+ todo = todo[:limit]
84
+ log(f"{len(todo)} 个视频待转写(模型 {model_name})")
85
+ if dry_run or not todo:
86
+ for t in todo:
87
+ log(f" · {t['path']}")
88
+ return 0
89
+
90
+ try:
91
+ from faster_whisper import WhisperModel
92
+ except ImportError:
93
+ raise SystemExit("没装转写组件。重新安装时带上 [media]:见 README 的“录播字幕”一节") from None
94
+ model = WhisperModel(model_name, device="cpu", compute_type="int8", cpu_threads=THREADS)
95
+ # 以前几次已经转写过的视频也算进去,跨次运行一样能认出重复
96
+ seen: dict[str, str] = {}
97
+ have = {r["path"] for r in con.execute("SELECT path FROM files")}
98
+ for r in con.execute("SELECT path FROM files").fetchall():
99
+ p = r["path"]
100
+ if p.lower().endswith(MEDIA) and transcript_path(p) in have and (FILES_DIR / p).exists():
101
+ seen.setdefault(cached_hash(con, p), transcript_path(p))
102
+ for item in todo:
103
+ media = FILES_DIR / item["path"]
104
+ out_rel = transcript_path(item["path"])
105
+ h = cached_hash(con, item["path"])
106
+ name = Path(item["path"]).name
107
+ title = item["title"] or Path(name).stem
108
+ if h in seen:
109
+ (FILES_DIR / out_rel).write_text(
110
+ f"# {title} 字幕稿\n\n这段视频和 `{seen[h]}` 完全相同,字幕稿见那一份。\n", encoding="utf-8")
111
+ register(con, item["course_id"], f"transcript:{item['path']}", out_rel, item["section"], f"字幕稿:{title}")
112
+ log(f" = {name}(和 {seen[h]} 相同)")
113
+ continue
114
+ t0 = time.time()
115
+ log(f" ♪ {item['path']}")
116
+ segments, info = model.transcribe(str(media), language="en", beam_size=5, vad_filter=True,
117
+ initial_prompt=prompt_for(item), condition_on_previous_text=False)
118
+ lines = [(s.start, s.text.strip()) for s in segments if s.text.strip()]
119
+ write_transcript(FILES_DIR / out_rel, title,
120
+ [f"视频:{name}", f"时长 {fmt_ts(info.duration)}",
121
+ f"识别:faster-whisper {model_name}", f"生成于 {now_iso()[:16].replace('T', ' ')} UTC"],
122
+ lines)
123
+ register(con, item["course_id"], f"transcript:{item['path']}", out_rel, item["section"], f"字幕稿:{title}")
124
+ seen[h] = out_rel
125
+ took = time.time() - t0
126
+ log(f" → {out_rel}({fmt_ts(info.duration)} 的视频用了 {fmt_ts(took)},"
127
+ f"{info.duration / max(took, 1):.1f}× 实时)")
128
+ return len(todo)
@@ -0,0 +1,113 @@
1
+ """新版本提示:每天最多查一次 PyPI 上的最新版本号,结果缓存在本地。
2
+
3
+ 朋友不会主动运行 monash update,修了 bug 也拿不到,所以在他们会看到的地方提一句:
4
+ 命令行(stderr 一行)、Claude 里的 study_todo / monash_status、monash doctor。
5
+
6
+ * 命令行只读缓存,绝不等网络;缓存过期就在后台线程里刷新,下一次运行才看得到。
7
+ * 查的是 PyPI 的公开 JSON 接口,不带任何身份信息。PyPI 上还没有这个包或者连不上时,
8
+ 退回去看 GitHub 上 main 分支的 __init__.py(1.1.0 以前就是这么查的)。
9
+ * 关掉:monash config update_check false,或者设 MONASH_KIT_NO_UPDATE_CHECK=1(CI 里自动关)。
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import os
15
+ import re
16
+ import threading
17
+ import time
18
+ import urllib.request
19
+
20
+ from . import __version__
21
+ from .paths import HOME, load_settings
22
+
23
+ PYPI_URL = "https://pypi.org/pypi/monash-study-kit/json"
24
+ VERSION_URL = "https://raw.githubusercontent.com/Waldo0926/monash-study-kit/main/src/monash_study_kit/__init__.py"
25
+ CACHE_FILE = HOME / "update-check.json"
26
+ EVERY = 24 * 3600
27
+ VERSION_RE = re.compile(r'__version__\s*=\s*"([0-9][0-9A-Za-z.\-+]*)"')
28
+
29
+
30
+ def parse(v: str) -> tuple:
31
+ """"0.10.2" → (0, 10, 2);认不出来的部分当 0,免得一个怪版本号让比较崩掉。"""
32
+ return tuple(int(p) if p.isdigit() else 0 for p in re.split(r"[.\-+]", v or "0"))
33
+
34
+
35
+ def is_newer(latest: str | None, current: str = __version__) -> bool:
36
+ return bool(latest) and parse(latest) > parse(current)
37
+
38
+
39
+ def enabled() -> bool:
40
+ if os.environ.get("MONASH_KIT_NO_UPDATE_CHECK") or os.environ.get("CI"):
41
+ return False
42
+ return bool(load_settings().get("update_check", True))
43
+
44
+
45
+ def read_cache() -> dict:
46
+ try:
47
+ return json.loads(CACHE_FILE.read_text(encoding="utf-8"))
48
+ except (OSError, ValueError):
49
+ return {}
50
+
51
+
52
+ def _get(url: str, timeout: float) -> bytes:
53
+ req = urllib.request.Request(url, headers={"User-Agent": f"monash-study-kit/{__version__}"})
54
+ with urllib.request.urlopen(req, timeout=timeout) as r:
55
+ return r.read(2_000_000)
56
+
57
+
58
+ def fetch_latest(timeout: float = 5) -> str | None:
59
+ try:
60
+ version = json.loads(_get(PYPI_URL, timeout))["info"]["version"]
61
+ if VERSION_RE.fullmatch(f'__version__ = "{version}"'):
62
+ return version
63
+ except (OSError, ValueError, KeyError, TypeError):
64
+ pass
65
+ m = VERSION_RE.search(_get(VERSION_URL, timeout)[:4096].decode("utf-8", "replace"))
66
+ return m.group(1) if m else None
67
+
68
+
69
+ def refresh(force: bool = False) -> dict:
70
+ """需要的话查一次,写缓存。网络出错就记下时间、下次再说,绝不抛出去。"""
71
+ cache = read_cache()
72
+ if not force and time.time() - cache.get("checked_at", 0) < EVERY:
73
+ return cache
74
+ try:
75
+ latest = fetch_latest()
76
+ except Exception: # noqa: BLE001 没网、GitHub 抽风:安静地跳过
77
+ latest = cache.get("latest")
78
+ cache = {"checked_at": time.time(), "latest": latest}
79
+ try:
80
+ HOME.mkdir(parents=True, exist_ok=True)
81
+ tmp = CACHE_FILE.with_suffix(".tmp")
82
+ tmp.write_text(json.dumps(cache), encoding="utf-8")
83
+ os.replace(tmp, CACHE_FILE)
84
+ except OSError:
85
+ pass
86
+ return cache
87
+
88
+
89
+ def refresh_in_background() -> None:
90
+ if enabled() and time.time() - read_cache().get("checked_at", 0) >= EVERY:
91
+ threading.Thread(target=refresh, name="update-check", daemon=True).start()
92
+
93
+
94
+ def available() -> str | None:
95
+ """缓存里有比当前新的版本就返回它(只读缓存,不联网)。"""
96
+ if not enabled():
97
+ return None
98
+ latest = read_cache().get("latest")
99
+ return latest if is_newer(latest) else None
100
+
101
+
102
+ def notice() -> str | None:
103
+ latest = available()
104
+ if not latest:
105
+ return None
106
+ return f"monash 有新版本 {latest}(现在是 {__version__}):运行 monash update 更新,然后重启 Claude Desktop"
107
+
108
+
109
+ def clear() -> None:
110
+ try:
111
+ CACHE_FILE.unlink()
112
+ except OSError:
113
+ pass
@@ -0,0 +1,312 @@
1
+ """课程笔记网页:Moodle 和 Ed Lessons 里链接到的讲义网站,抓下来转成文字,进全文索引。
2
+
3
+ 很多课的讲义不在 Moodle / Ed 里,而在老师的公开网站上(FIT2102 → tgdwyer.github.io,
4
+ FIT2109 → yqtian-se.github.io)。只抓被课程直接链接到的那几页,不顺着页面里的链接往下爬。
5
+
6
+ 哪些站算“讲义站”:
7
+ * github.io / gitlab.io / readthedocs.io / netlify.app / pages.dev 这类静态站;
8
+ * 或者同一门课里被链接了很多次的站(CLASS_SITE_MIN 次以上);
9
+ * 学校官网、Moodle / Ed 自己、视频、会议、网盘、Google 文档之类一律不抓。
10
+
11
+ 存在 FILES_DIR/<课程文件夹>/Course notes (web)/<标题>.md,开头写来源网址。放在课件目录里,
12
+ list_files / read_file / search_content 就都能直接用。每页最多每 REFRESH_DAYS 天重抓一次,
13
+ 带 ETag / Last-Modified 条件请求;请求之间歇 GAP 秒;robots.txt 不让抓的页面跳过。
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import hashlib
18
+ import re
19
+ import sqlite3
20
+ import time
21
+ import urllib.error
22
+ import urllib.parse
23
+ import urllib.request
24
+ import urllib.robotparser
25
+ from collections import Counter
26
+ from datetime import datetime, timedelta, timezone
27
+
28
+ from . import htmldom
29
+ from .edlib import DB_PATH as ED_DB
30
+ from .moodlelib import FILES_DIR, now_iso
31
+ from .syncer import safe_name
32
+
33
+ UA = "monash-study-kit (personal study helper; fetches course notes linked from Moodle/Ed)"
34
+ ROBOTS_AGENT = "monash-study-kit" # robots.txt 里按这个名字匹配 User-agent
35
+ ROBOTS_TTL = 24 * 3600
36
+ STATIC_HOSTS = ("github.io", "gitlab.io", "readthedocs.io", "netlify.app", "pages.dev", "vercel.app")
37
+ BLOCKED_HOSTS = ("monash.edu", "monash.edu.my", "edstem.org", "zoom.us", "youtube.com", "youtu.be",
38
+ "google.com", "googleusercontent.com", "sharepoint.com", "microsoft.com", "office.com",
39
+ "okta.com", "instructure.com", "panopto.com", "vimeo.com", "github.com")
40
+ SKIP_EXT = re.compile(r"\.(zip|tar|gz|tgz|7z|rar|mp4|mov|mp3|png|jpe?g|gif|svg|ipynb|pptx?|docx?|xlsx?)$", re.I)
41
+ CLASS_SITE_MIN = 5 # 同一门课链到同一个站这么多次,就当它是讲义站
42
+ MAX_PAGES_PER_COURSE = 200
43
+ MAX_BYTES = 5 * 1024 * 1024
44
+ REFRESH_DAYS = 7
45
+ GAP = 1.0
46
+ FOLDER = "Course notes (web)"
47
+
48
+ SCHEMA = """
49
+ CREATE TABLE IF NOT EXISTS web_pages (
50
+ url TEXT PRIMARY KEY, course TEXT, title TEXT, path TEXT, etag TEXT, last_modified TEXT,
51
+ status TEXT, fetched_at TEXT
52
+ );
53
+ """
54
+
55
+
56
+ # ---------------------------------------------------------------- 挑页面
57
+
58
+ def host_of(url: str) -> str:
59
+ return (urllib.parse.urlsplit(url).hostname or "").lower()
60
+
61
+
62
+ def _host_matches(host: str, suffixes: tuple) -> bool:
63
+ return any(host == s or host.endswith("." + s) for s in suffixes)
64
+
65
+
66
+ def normalize(url: str) -> str | None:
67
+ p = urllib.parse.urlsplit(url.strip())
68
+ if p.scheme not in ("http", "https") or not p.hostname:
69
+ return None
70
+ return urllib.parse.urlunsplit((p.scheme, p.netloc.lower(), p.path or "/", p.query, ""))
71
+
72
+
73
+ def choose(links: list[tuple[str, str, str]]) -> list[tuple[str, str, str]]:
74
+ """[(课号, url, 标题)] → 要抓的那些。按课去重、过滤、每门课限量。"""
75
+ per_course: dict[str, dict[str, str]] = {}
76
+ for code, url, title in links:
77
+ u = normalize(url)
78
+ if code and u:
79
+ per_course.setdefault(code, {}).setdefault(u, title or "")
80
+ out = []
81
+ for code, urls in per_course.items():
82
+ counts = Counter(host_of(u) for u in urls)
83
+ picked = []
84
+ for u, title in urls.items():
85
+ host = host_of(u)
86
+ if _host_matches(host, BLOCKED_HOSTS) or SKIP_EXT.search(urllib.parse.urlsplit(u).path):
87
+ continue
88
+ if _host_matches(host, STATIC_HOSTS) or counts[host] >= CLASS_SITE_MIN:
89
+ picked.append((code, u, title))
90
+ out += picked[:MAX_PAGES_PER_COURSE]
91
+ return out
92
+
93
+
94
+ def collect_links(moodle_con: sqlite3.Connection) -> list[tuple[str, str, str]]:
95
+ """跟踪的课里所有外部链接:Moodle 的 links 表 + Ed Lessons 的网页 slide。"""
96
+ rows = [(r["code"], r["url"], r["title"]) for r in moodle_con.execute(
97
+ "SELECT c.code, l.url, l.title FROM links l JOIN courses c ON c.id = l.course_id WHERE c.tracked = 1")]
98
+ if ED_DB.exists():
99
+ try:
100
+ con = sqlite3.connect(f"file:{ED_DB.as_posix()}?mode=ro", uri=True)
101
+ for code, url, title in con.execute(
102
+ "SELECT c.code, f.url, f.title FROM lesson_files f JOIN courses c ON c.id = f.course_id "
103
+ "WHERE c.tracked = 1 AND f.type = 'webpage' AND f.url IS NOT NULL"):
104
+ m = re.search(r"[A-Z]{3}\d{4}", code or "")
105
+ rows.append((m.group(0) if m else None, url, title))
106
+ con.close()
107
+ except sqlite3.Error:
108
+ pass
109
+ return rows
110
+
111
+
112
+ # ---------------------------------------------------------------- HTML → 文字
113
+
114
+ DROP = {"script", "style", "nav", "header", "footer", "aside", "noscript", "svg", "form", "button",
115
+ "iframe", "template", "select"}
116
+ BLOCK = {"p", "div", "section", "article", "main", "ul", "ol", "table", "tr", "blockquote", "figure",
117
+ "details", "summary", "dl", "dt", "dd", "br", "hr"}
118
+
119
+
120
+ def html_to_text(html_text: str) -> tuple[str, str]:
121
+ """返回 (标题, Markdown 风格的正文)。只保留阅读需要的:标题层级、段落、列表、代码块。"""
122
+ doc = htmldom.parse(html_text)
123
+ title_node = doc.find("title")
124
+ title = title_node.text() if title_node else ""
125
+ root = doc.find("main") or doc.find("article") or doc.find(role="main") or doc.find("body") or doc
126
+ h1 = root.find("h1")
127
+ if h1 and h1.text():
128
+ title = h1.text()
129
+ out: list[str] = []
130
+
131
+ def walk(n):
132
+ for ch in n.children:
133
+ if isinstance(ch, str):
134
+ out.append(re.sub(r"\s+", " ", ch))
135
+ continue
136
+ tag = ch.tag
137
+ if tag in DROP or ch.get("hidden") is not None or ch.get("role") == "tooltip" \
138
+ or ch.classes & {"sr-only", "glossary-popup", "tooltip", "tooltip-text"}:
139
+ continue # 术语悬浮提示之类,混进正文会把句子打断
140
+ if tag == "pre":
141
+ code = "".join(t for t in _texts(ch)).rstrip()
142
+ out.append(f"\n\n```\n{code}\n```\n\n")
143
+ elif tag in ("h1", "h2", "h3", "h4", "h5", "h6"):
144
+ out.append(f"\n\n{'#' * int(tag[1])} {ch.text()}\n\n")
145
+ elif tag == "li":
146
+ out.append("\n- ")
147
+ walk(ch)
148
+ elif tag == "code":
149
+ out.append(f"`{ch.text()}`")
150
+ elif tag in ("td", "th"):
151
+ walk(ch)
152
+ out.append(" | ")
153
+ elif tag == "img":
154
+ alt = ch.get("alt")
155
+ if alt:
156
+ out.append(f"[图:{alt}]")
157
+ else:
158
+ if tag in BLOCK:
159
+ out.append("\n\n")
160
+ walk(ch)
161
+ if tag in BLOCK:
162
+ out.append("\n\n")
163
+
164
+ walk(root)
165
+ # 只整理代码块外面的空白:代码的缩进要原样保留(Haskell、Python 靠缩进)
166
+ parts = re.split(r"(\n\n```\n.*?\n```\n\n)", "".join(out), flags=re.S)
167
+ for i in range(0, len(parts), 2):
168
+ t = re.sub(r"[ \t]+\n", "\n", parts[i])
169
+ t = re.sub(r"\n[ \t]+", "\n", t)
170
+ parts[i] = re.sub(r"\n{3,}", "\n\n", t)
171
+ text = re.sub(r"\n{3,}", "\n\n", "".join(parts)).strip()
172
+ return " ".join(title.split()), text
173
+
174
+
175
+ def _texts(n):
176
+ for ch in n.children:
177
+ if isinstance(ch, str):
178
+ yield ch
179
+ else:
180
+ if ch.tag == "br":
181
+ yield "\n"
182
+ yield from _texts(ch)
183
+
184
+
185
+ # ---------------------------------------------------------------- 抓取
186
+
187
+ _robots: dict[str, tuple[float, urllib.robotparser.RobotFileParser | None]] = {}
188
+
189
+
190
+ def load_robots(site: str) -> urllib.robotparser.RobotFileParser | None:
191
+ """取 site 的 robots.txt。规则跟 urllib.robotparser.read() 一样:401/403 当全站禁止,
192
+ 别的 4xx 当没有限制;连不上返回 None(说不清,交给后面的正式请求去报错)。"""
193
+ rp = urllib.robotparser.RobotFileParser(site + "/robots.txt")
194
+ req = urllib.request.Request(site + "/robots.txt", headers={"User-Agent": UA})
195
+ try:
196
+ with urllib.request.urlopen(req, timeout=15) as r:
197
+ rp.parse(r.read(512 * 1024).decode("utf-8", "replace").splitlines())
198
+ except urllib.error.HTTPError as e:
199
+ if e.code in (401, 403):
200
+ rp.disallow_all = True
201
+ else:
202
+ rp.allow_all = True
203
+ except (urllib.error.URLError, TimeoutError, OSError):
204
+ return None
205
+ return rp
206
+
207
+
208
+ def robots_allowed(url: str, loader=load_robots) -> bool:
209
+ """robots.txt 允不允许抓这一页。每个站一天最多取一次 robots.txt(MCP 会一直开着)。"""
210
+ p = urllib.parse.urlsplit(url)
211
+ site = f"{p.scheme}://{p.netloc}"
212
+ at, rp = _robots.get(site, (0.0, None))
213
+ if site not in _robots or time.time() - at > ROBOTS_TTL:
214
+ rp = loader(site)
215
+ _robots[site] = (time.time(), rp)
216
+ return rp is None or rp.can_fetch(ROBOTS_AGENT, url)
217
+
218
+
219
+ def fetch(url: str, etag: str | None = None, last_modified: str | None = None) -> dict:
220
+ """{"status": "ok"/"unchanged"/"skipped"/"error", ...}。不带任何登录信息。"""
221
+ if not robots_allowed(url):
222
+ return {"status": "skipped", "reason": "robots.txt 不允许"}
223
+ req = urllib.request.Request(url, headers={"User-Agent": UA, "Accept": "text/html,application/xhtml+xml"})
224
+ if etag:
225
+ req.add_header("If-None-Match", etag)
226
+ if last_modified:
227
+ req.add_header("If-Modified-Since", last_modified)
228
+ try:
229
+ with urllib.request.urlopen(req, timeout=30) as r:
230
+ ctype = (r.headers.get("Content-Type") or "").split(";")[0].strip().lower()
231
+ if ctype not in ("text/html", "application/xhtml+xml"):
232
+ return {"status": "skipped", "reason": ctype or "未知类型"}
233
+ body = r.read(MAX_BYTES + 1)
234
+ if len(body) > MAX_BYTES:
235
+ return {"status": "skipped", "reason": "太大"}
236
+ charset = r.headers.get_content_charset() or "utf-8"
237
+ return {"status": "ok", "html": body.decode(charset, "replace"), "final_url": r.geturl(),
238
+ "etag": r.headers.get("ETag"), "last_modified": r.headers.get("Last-Modified")}
239
+ except urllib.error.HTTPError as e:
240
+ if e.code == 304:
241
+ return {"status": "unchanged"}
242
+ return {"status": "error", "reason": f"HTTP {e.code}"}
243
+ except (urllib.error.URLError, TimeoutError, OSError) as e:
244
+ return {"status": "error", "reason": str(getattr(e, "reason", e))}
245
+
246
+
247
+ def _course_folder(moodle_con, code: str) -> tuple[int | None, str]:
248
+ row = moodle_con.execute("SELECT id, folder FROM courses WHERE code=? ORDER BY tracked DESC, id DESC LIMIT 1",
249
+ (code,)).fetchone()
250
+ return (row["id"], row["folder"]) if row else (None, f"{code} (Ed)")
251
+
252
+
253
+ def _dest(folder: str, title: str, url: str, taken: set[str]) -> str:
254
+ base = safe_name(title or urllib.parse.urlsplit(url).path.strip("/").replace("/", " ") or host_of(url), 70)
255
+ rel = f"{folder}/{FOLDER}/{base}.md"
256
+ if rel in taken:
257
+ rel = f"{folder}/{FOLDER}/{base} ({hashlib.sha1(url.encode()).hexdigest()[:6]}).md"
258
+ return rel
259
+
260
+
261
+ def sync(moodle_con: sqlite3.Connection, log=print, now: datetime | None = None, fetcher=fetch) -> dict:
262
+ """抓该抓的讲义页。返回 {"fetched", "unchanged", "skipped", "errors"}。"""
263
+ moodle_con.executescript(SCHEMA)
264
+ now = now or datetime.now(timezone.utc)
265
+ stale = (now - timedelta(days=REFRESH_DAYS)).isoformat(timespec="seconds")
266
+ retry = (now - timedelta(days=1)).isoformat(timespec="seconds") # 抓失败的第二天再试
267
+ known = {r["url"]: dict(r) for r in moodle_con.execute("SELECT * FROM web_pages")}
268
+ taken = {r["path"] for r in known.values() if r["path"]}
269
+ stats = {"fetched": 0, "unchanged": 0, "skipped": 0, "errors": 0}
270
+ last = 0.0
271
+ for code, url, link_title in choose(collect_links(moodle_con)):
272
+ prev = known.get(url)
273
+ if prev and (prev["fetched_at"] or "") > (retry if (prev["status"] or "").startswith("error") else stale):
274
+ continue
275
+ wait = GAP - (time.monotonic() - last)
276
+ if wait > 0:
277
+ time.sleep(wait)
278
+ last = time.monotonic()
279
+ res = fetcher(url, (prev or {}).get("etag"), (prev or {}).get("last_modified"))
280
+ course_id, folder = _course_folder(moodle_con, code)
281
+ path = (prev or {}).get("path")
282
+ if res["status"] == "ok":
283
+ title, text = html_to_text(res["html"])
284
+ if len(text) < 200:
285
+ res = {"status": "skipped", "reason": "没有正文"}
286
+ else:
287
+ path = path or _dest(folder, title or link_title, url, taken)
288
+ taken.add(path)
289
+ dest = FILES_DIR / path
290
+ dest.parent.mkdir(parents=True, exist_ok=True)
291
+ dest.write_text(f"# {title or link_title}\n\n来源:{url}\n\n{text}\n", encoding="utf-8")
292
+ if course_id is not None:
293
+ moodle_con.execute(
294
+ "INSERT INTO files (course_id, source, cmid, path, size, section, title, synced_at)"
295
+ " VALUES (?,?,NULL,?,?,?,?,?) ON CONFLICT(course_id, source) DO UPDATE SET"
296
+ " path=excluded.path, size=excluded.size, synced_at=excluded.synced_at",
297
+ (course_id, f"web:{url}", path, dest.stat().st_size, FOLDER, title or link_title, now_iso()))
298
+ log(f" ↓ 讲义网页 {path}")
299
+ link_title = title or link_title
300
+ key = {"ok": "fetched", "unchanged": "unchanged", "skipped": "skipped"}.get(res["status"], "errors")
301
+ stats[key] += 1
302
+ if key == "errors":
303
+ log(f" ! {url}: {res.get('reason')}")
304
+ moodle_con.execute(
305
+ "INSERT OR REPLACE INTO web_pages VALUES (?,?,?,?,?,?,?,?)",
306
+ (url, code, link_title, path if res["status"] in ("ok", "unchanged") else (prev or {}).get("path"),
307
+ res.get("etag") or (prev or {}).get("etag"),
308
+ res.get("last_modified") or (prev or {}).get("last_modified"),
309
+ res["status"] if res["status"] != "error" else f"error: {res.get('reason')}",
310
+ now.isoformat(timespec="seconds")))
311
+ moodle_con.commit()
312
+ return stats