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