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,121 @@
|
|
|
1
|
+
"""功能大全和预设提示:让朋友知道能问什么。
|
|
2
|
+
|
|
3
|
+
工具不是靠关键词触发的(Claude 按意思挑工具),但人不知道有什么功能就想不到去问。所以:
|
|
4
|
+
* FEATURES:按类别列出能做的事,每条一句示例问法和对应的命令。`monash help` 打印它,
|
|
5
|
+
MCP 的“功能大全”提示也用它,两边不会对不上。
|
|
6
|
+
* PROMPTS:MCP 预设提示。它们只在用户从 Claude 的“+”菜单里选中时才发给 Claude,
|
|
7
|
+
平时不占额度,这是它们比多加一个 help 工具划算的地方(工具清单每次对话都要整份发)。
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
# (类别, [(能做什么, 示例问法, 命令行)])
|
|
12
|
+
FEATURES = [
|
|
13
|
+
("这周要做什么", [
|
|
14
|
+
("本周待办:截止、可能漏交、Ed 没做完的 lesson、最新公告", "这周我有什么要做的?", "monash todo"),
|
|
15
|
+
("截止日期(没登录时也能查)", "FIT2102 接下来两周有什么要交?", "monash due FIT2102"),
|
|
16
|
+
("作业提交状态、可能漏交", "我有没有漏交什么作业?", "monash moodle assignments --missing"),
|
|
17
|
+
]),
|
|
18
|
+
("课程资料", [
|
|
19
|
+
("全文搜课件、课程笔记网页、Ed 课件、录播字幕稿,带页码/时间戳", "哪一周讲了 monad?在哪份讲义第几页?",
|
|
20
|
+
'monash grep "monad"'),
|
|
21
|
+
("读课件原文、帮你解释", "帮我看看 A2 的 spec 要求什么", "monash open"),
|
|
22
|
+
("某周的课件和外部链接", "FIT2109 第 5 周有哪些资料?", "monash moodle ls FIT2109 5"),
|
|
23
|
+
]),
|
|
24
|
+
("Ed 论坛", [
|
|
25
|
+
("上次看过之后的新帖和新回复", "Ed 上有什么新消息?", "monash ed new"),
|
|
26
|
+
("我发的/关注的帖子有没有人回", "我上次问的问题有人回了吗?", "monash ed following"),
|
|
27
|
+
("搜帖子、读全文", "有没有人问过 A1 能不能用 lodash?", "monash ed search FIT2102 lodash"),
|
|
28
|
+
("整节读 Ed lesson:正文、阅读网页、PDF 按页序拼好,附测验题", "带我过一遍 FIT2109 第 3 周的 pre-class",
|
|
29
|
+
"monash ed read FIT2109 'W3 Pre-Class'"),
|
|
30
|
+
("Ed Lessons 进度和测验题(复习用)", "用第 3 周的测验题帮我复习", "monash ed quiz FIT2109 --module 'Week 3'"),
|
|
31
|
+
]),
|
|
32
|
+
("成绩和通知", [
|
|
33
|
+
("各课总分、每项得分和反馈", "我 FIT2109 现在成绩多少?", "monash moodle grades FIT2109"),
|
|
34
|
+
("Moodle 公告", "FIT2102 最近有什么公告?", "monash moodle news FIT2102"),
|
|
35
|
+
("Moodle 私信(只读)", "Moodle 上有人给我发消息吗?", "monash moodle messages"),
|
|
36
|
+
]),
|
|
37
|
+
("维护", [
|
|
38
|
+
("登录 / 重新登录 Moodle", "帮我重新登录 Moodle", "monash login"),
|
|
39
|
+
("马上同步一次", "同步一下最新的", "monash sync"),
|
|
40
|
+
("出问题时体检", "monash 用不了了", "monash doctor"),
|
|
41
|
+
]),
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def render(cli: bool = True) -> str:
|
|
46
|
+
"""cli=True 给终端看(带命令);False 给 Claude 看(只有功能和问法)。"""
|
|
47
|
+
out = ["能做的事(直接用自己的话问就行,下面只是例子):" if not cli else "monash 能做的事:", ""]
|
|
48
|
+
for cat, items in FEATURES:
|
|
49
|
+
out.append(f"■ {cat}")
|
|
50
|
+
for what, ask, cmd in items:
|
|
51
|
+
out.append(f" · {what}")
|
|
52
|
+
out.append(f" 问:“{ask}”" + (f" 命令:{cmd}" if cli else ""))
|
|
53
|
+
out.append("")
|
|
54
|
+
out.append("不能做的:交作业、做测验、在 Ed 上发帖,这些要自己去网页上操作。")
|
|
55
|
+
if cli:
|
|
56
|
+
out.append("每个命令都有 --help;Claude 里输入框的“+”菜单里有 monash 的预设提示,点一下就能用。")
|
|
57
|
+
return "\n".join(out)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# ---------------------------------------------------------------- MCP 预设提示
|
|
61
|
+
# name → (标题, 说明, [(参数名, 说明, 必填)], 生成用户消息的函数)
|
|
62
|
+
|
|
63
|
+
def _todo(a):
|
|
64
|
+
return ("这周我有什么要做的?按截止时间排,标出可能漏交的作业,"
|
|
65
|
+
"再列 Ed 上还没做完的 lesson 和最近的公告。最后给我一个今天先做什么的建议。")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _ed(a):
|
|
69
|
+
course = f"(只看 {a['course']})" if a.get("course") else ""
|
|
70
|
+
return (f"Ed 上有什么新消息{course}?先说老师的公告和回答,再说我发的或关注的帖子有没有新回复,"
|
|
71
|
+
"其余的一句话概括就行。")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _topic(a):
|
|
75
|
+
course = f"在 {a['course']} 里," if a.get("course") else ""
|
|
76
|
+
return (f"{course}“{a['topic']}”在哪份资料里讲过?列出课件名和页码或录播时间戳,"
|
|
77
|
+
"然后读原文,用简单的话给我解释一遍。")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _quiz(a):
|
|
81
|
+
week = f"第 {a['week']} 周" if a.get("week") else "最近一周"
|
|
82
|
+
return (f"用 {a['course']} Ed Lessons 里{week}的测验题帮我复习:一次出一道,等我回答后再判断对错并解释。"
|
|
83
|
+
"Ed 不公开答案,请根据课件判断,拿不准的地方直接说。")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _grades(a):
|
|
87
|
+
return "我各门课现在的成绩和老师反馈怎么样?有没有需要特别注意的?"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _help(a):
|
|
91
|
+
return "我刚装了 monash。告诉我你能帮我做什么,按类别列出来,每类给我几句可以直接问的例子。\n\n" + render(cli=False)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
PROMPTS = {
|
|
95
|
+
"help": ("功能大全", "列出能做的所有事,每条附一句示例问法", [], _help),
|
|
96
|
+
"weekly_todo": ("本周待办", "这周要做什么、有没有漏交", [], _todo),
|
|
97
|
+
"ed_catchup": ("Ed 新消息", "公告、老师回答、我的帖子有没有人回", [("course", "只看某门课,如 FIT2102", False)], _ed),
|
|
98
|
+
"find_topic": ("找知识点", "某个概念在哪份讲义/哪节录播,并解释",
|
|
99
|
+
[("topic", "要找的知识点,如 monad", True), ("course", "课号,可不填", False)], _topic),
|
|
100
|
+
"quiz_review": ("测验题复习", "用 Ed Lessons 的测验题一题一题练",
|
|
101
|
+
[("course", "课号,如 FIT2109", True), ("week", "第几周,可不填", False)], _quiz),
|
|
102
|
+
"grades": ("成绩和反馈", "各课成绩和老师反馈", [], _grades),
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def prompt_list() -> list[dict]:
|
|
107
|
+
return [{"name": name, "title": title, "description": desc,
|
|
108
|
+
"arguments": [{"name": n, "description": d, "required": req} for n, d, req in args]}
|
|
109
|
+
for name, (title, desc, args, _) in PROMPTS.items()]
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def get_prompt(name: str, arguments: dict | None) -> dict:
|
|
113
|
+
if name not in PROMPTS:
|
|
114
|
+
raise KeyError(name)
|
|
115
|
+
title, desc, args, build = PROMPTS[name]
|
|
116
|
+
arguments = {k: v for k, v in (arguments or {}).items() if v not in (None, "")}
|
|
117
|
+
missing = [n for n, _, req in args if req and n not in arguments]
|
|
118
|
+
if missing:
|
|
119
|
+
raise ValueError(f"缺少参数:{', '.join(missing)}")
|
|
120
|
+
return {"description": desc,
|
|
121
|
+
"messages": [{"role": "user", "content": {"type": "text", "text": build(arguments)}}]}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""极简 HTML 树,给 Moodle 页面解析用(成绩表、论坛、作业、测验)。
|
|
2
|
+
|
|
3
|
+
标准库的 HTMLParser 只给事件流,这里把它搭成一棵树,再提供 find / find_all /
|
|
4
|
+
text 这几个最常用的操作。Moodle 的 HTML 大体规整,不需要 html5lib 那种容错。
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import html as _html
|
|
9
|
+
from html.parser import HTMLParser
|
|
10
|
+
|
|
11
|
+
VOID = {"area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta",
|
|
12
|
+
"source", "track", "wbr"}
|
|
13
|
+
# 这些标签遇到同级的新标签时自动闭合(HTML 允许省略结束标签)
|
|
14
|
+
AUTOCLOSE = {"p": {"p", "div", "ul", "ol", "table", "h1", "h2", "h3", "h4"},
|
|
15
|
+
"li": {"li"}, "tr": {"tr"}, "td": {"td", "th", "tr"}, "th": {"td", "th", "tr"},
|
|
16
|
+
"option": {"option"}}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Node:
|
|
20
|
+
__slots__ = ("tag", "attrs", "children", "parent")
|
|
21
|
+
|
|
22
|
+
def __init__(self, tag: str, attrs: dict | None = None, parent: "Node | None" = None):
|
|
23
|
+
self.tag = tag
|
|
24
|
+
self.attrs = attrs or {}
|
|
25
|
+
self.children: list[Node | str] = []
|
|
26
|
+
self.parent = parent
|
|
27
|
+
|
|
28
|
+
def get(self, key: str, default=None):
|
|
29
|
+
return self.attrs.get(key, default)
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def classes(self) -> set[str]:
|
|
33
|
+
return set((self.attrs.get("class") or "").split())
|
|
34
|
+
|
|
35
|
+
def iter(self):
|
|
36
|
+
for ch in self.children:
|
|
37
|
+
if isinstance(ch, Node):
|
|
38
|
+
yield ch
|
|
39
|
+
yield from ch.iter()
|
|
40
|
+
|
|
41
|
+
def _match(self, tag, cls, attrs) -> bool:
|
|
42
|
+
if tag and self.tag not in (tag if isinstance(tag, (set, tuple, list)) else {tag}):
|
|
43
|
+
return False
|
|
44
|
+
if cls and not set(cls.split()) <= self.classes:
|
|
45
|
+
return False
|
|
46
|
+
for k, v in (attrs or {}).items():
|
|
47
|
+
have = self.attrs.get(k)
|
|
48
|
+
if v is True:
|
|
49
|
+
if have is None:
|
|
50
|
+
return False
|
|
51
|
+
elif callable(v):
|
|
52
|
+
if have is None or not v(have):
|
|
53
|
+
return False
|
|
54
|
+
elif have != v:
|
|
55
|
+
return False
|
|
56
|
+
return True
|
|
57
|
+
|
|
58
|
+
def find_all(self, tag=None, cls: str | None = None, **attrs) -> list["Node"]:
|
|
59
|
+
attrs = {k.rstrip("_").replace("__", "-"): v for k, v in attrs.items()}
|
|
60
|
+
return [n for n in self.iter() if n._match(tag, cls, attrs)]
|
|
61
|
+
|
|
62
|
+
def find(self, tag=None, cls: str | None = None, **attrs) -> "Node | None":
|
|
63
|
+
attrs = {k.rstrip("_").replace("__", "-"): v for k, v in attrs.items()}
|
|
64
|
+
return next((n for n in self.iter() if n._match(tag, cls, attrs)), None)
|
|
65
|
+
|
|
66
|
+
def text(self, sep: str = " ") -> str:
|
|
67
|
+
parts: list[str] = []
|
|
68
|
+
|
|
69
|
+
def walk(n: Node):
|
|
70
|
+
for ch in n.children:
|
|
71
|
+
if isinstance(ch, str):
|
|
72
|
+
parts.append(ch)
|
|
73
|
+
elif ch.tag not in ("script", "style"):
|
|
74
|
+
if ch.tag in ("br", "p", "div", "li", "tr") and parts:
|
|
75
|
+
parts.append(sep)
|
|
76
|
+
walk(ch)
|
|
77
|
+
walk(self)
|
|
78
|
+
return " ".join("".join(parts).split()) if sep == " " else \
|
|
79
|
+
"\n".join(" ".join(line.split()) for line in "".join(parts).split(sep) if line.strip())
|
|
80
|
+
|
|
81
|
+
def inner_html(self) -> str:
|
|
82
|
+
out = []
|
|
83
|
+
|
|
84
|
+
def walk(n: Node):
|
|
85
|
+
for ch in n.children:
|
|
86
|
+
if isinstance(ch, str):
|
|
87
|
+
out.append(_html.escape(ch, quote=False))
|
|
88
|
+
else:
|
|
89
|
+
attrs = "".join(f' {k}="{_html.escape(v or "")}"' for k, v in ch.attrs.items())
|
|
90
|
+
out.append(f"<{ch.tag}{attrs}>")
|
|
91
|
+
if ch.tag not in VOID:
|
|
92
|
+
walk(ch)
|
|
93
|
+
out.append(f"</{ch.tag}>")
|
|
94
|
+
walk(self)
|
|
95
|
+
return "".join(out)
|
|
96
|
+
|
|
97
|
+
def __repr__(self):
|
|
98
|
+
return f"<{self.tag} {self.attrs.get('class', '')!r}>"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class _Builder(HTMLParser):
|
|
102
|
+
def __init__(self):
|
|
103
|
+
super().__init__(convert_charrefs=True)
|
|
104
|
+
self.root = Node("#root")
|
|
105
|
+
self.cur = self.root
|
|
106
|
+
|
|
107
|
+
def handle_starttag(self, tag, attrs):
|
|
108
|
+
closes = AUTOCLOSE.get(self.cur.tag)
|
|
109
|
+
if closes and tag in closes:
|
|
110
|
+
self._close(self.cur.tag)
|
|
111
|
+
node = Node(tag, {k: (v if v is not None else "") for k, v in attrs}, self.cur)
|
|
112
|
+
self.cur.children.append(node)
|
|
113
|
+
if tag not in VOID:
|
|
114
|
+
self.cur = node
|
|
115
|
+
|
|
116
|
+
def handle_startendtag(self, tag, attrs):
|
|
117
|
+
node = Node(tag, {k: (v if v is not None else "") for k, v in attrs}, self.cur)
|
|
118
|
+
self.cur.children.append(node)
|
|
119
|
+
|
|
120
|
+
def handle_endtag(self, tag):
|
|
121
|
+
self._close(tag)
|
|
122
|
+
|
|
123
|
+
def _close(self, tag):
|
|
124
|
+
n = self.cur
|
|
125
|
+
while n is not None and n.tag != tag:
|
|
126
|
+
n = n.parent
|
|
127
|
+
if n is not None and n.parent is not None:
|
|
128
|
+
self.cur = n.parent
|
|
129
|
+
|
|
130
|
+
def handle_data(self, data):
|
|
131
|
+
self.cur.children.append(data)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def parse(html_text: str) -> Node:
|
|
135
|
+
b = _Builder()
|
|
136
|
+
b.feed(html_text)
|
|
137
|
+
b.close()
|
|
138
|
+
return b.root
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def form_fields(form: Node) -> list[tuple[str, str]]:
|
|
142
|
+
"""像浏览器一样收集一个表单要提交的字段(不含提交按钮)。"""
|
|
143
|
+
out = []
|
|
144
|
+
for n in form.iter():
|
|
145
|
+
name = n.get("name")
|
|
146
|
+
if not name or n.get("disabled") is not None:
|
|
147
|
+
continue
|
|
148
|
+
if n.tag == "input":
|
|
149
|
+
t = (n.get("type") or "text").lower()
|
|
150
|
+
if t in ("submit", "button", "image", "file", "reset"):
|
|
151
|
+
continue
|
|
152
|
+
if t in ("checkbox", "radio") and n.get("checked") is None:
|
|
153
|
+
continue
|
|
154
|
+
out.append((name, n.get("value") or ("on" if t in ("checkbox", "radio") else "")))
|
|
155
|
+
elif n.tag == "textarea":
|
|
156
|
+
out.append((name, "".join(c for c in n.children if isinstance(c, str))))
|
|
157
|
+
elif n.tag == "select":
|
|
158
|
+
opts = n.find_all("option")
|
|
159
|
+
chosen = [o for o in opts if o.get("selected") is not None] or opts[:1]
|
|
160
|
+
for o in chosen:
|
|
161
|
+
out.append((name, o.get("value") if o.get("value") is not None else o.text()))
|
|
162
|
+
return out
|
monash_study_kit/jobs.py
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""同步、续期、状态:`monash sync` 和 MCP 的后台线程共用。
|
|
2
|
+
|
|
3
|
+
一次完整同步 = Ed 帖子 + Ed Lessons(有令牌的话)→ Moodle 课件(登录着的话)→ 全文索引。
|
|
4
|
+
哪一边没登录就跳过那一边,在状态里记一笔,不影响另一边。
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
import time
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from . import content_index, edlib, edsync, lessons
|
|
16
|
+
from .edlib import EdAuthError
|
|
17
|
+
from .moodlelib import DATA_DIR, MoodleAuthError, MoodleClient, db_connect, get_state, now_iso, set_state
|
|
18
|
+
from .paths import HOME, load_settings
|
|
19
|
+
from .syncer import CourseSync, clean, course_code, course_folder, write_links_md
|
|
20
|
+
|
|
21
|
+
STATUS_FILE = HOME / "status.json"
|
|
22
|
+
LOCK_FILE = HOME / "sync.lock"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# ---------------------------------------------------------------- Moodle 课程
|
|
26
|
+
|
|
27
|
+
def load_courses(client: MoodleClient, con) -> list[dict]:
|
|
28
|
+
courses = client.courses()
|
|
29
|
+
for c in courses:
|
|
30
|
+
con.execute("INSERT INTO courses (id, code, folder, fullname) VALUES (?,?,?,?)"
|
|
31
|
+
" ON CONFLICT(id) DO UPDATE SET code=excluded.code, folder=excluded.folder,"
|
|
32
|
+
" fullname=excluded.fullname",
|
|
33
|
+
(c["id"], course_code(c), course_folder(c), clean(c["fullname"])))
|
|
34
|
+
con.commit()
|
|
35
|
+
return courses
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
TERM_RE = re.compile(r"\b(S1|S2|Summer|Winter)\b(?:\s+MUM)?\s*(20\d\d)?", re.I)
|
|
39
|
+
TERM_ORDER = {"s1": 1, "winter": 2, "s2": 3, "summer": 4}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def course_term(course: dict) -> tuple[int, int] | None:
|
|
43
|
+
"""课名或分类里的学期标签 → (年, 学期序号)。"FIT2102 … - S2 2026" → (2026, 3)。"""
|
|
44
|
+
text = f"{clean(course.get('fullname', ''))} {course.get('coursecategory') or ''}"
|
|
45
|
+
best = None
|
|
46
|
+
for m in TERM_RE.finditer(text):
|
|
47
|
+
year = m.group(2) or (re.search(r"20\d\d", text) or [None])[0]
|
|
48
|
+
if year:
|
|
49
|
+
t = (int(year), TERM_ORDER[m.group(1).lower()])
|
|
50
|
+
best = max(best, t) if best else t
|
|
51
|
+
return best
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def current_courses(courses: list[dict], now: float | None = None) -> list[dict]:
|
|
55
|
+
"""本学期的课。
|
|
56
|
+
|
|
57
|
+
Moodle 上的开课/结课日期不可靠(有的课 3 月就“开课”、下学期的课也已经挂出来),所以主要看
|
|
58
|
+
课名/分类里的学期标签:已经开课的课里,最新的那个学期就是本学期。没有学期标签的课退回按日期判断。
|
|
59
|
+
"""
|
|
60
|
+
now = now or time.time()
|
|
61
|
+
coded = [c for c in courses if course_code(c)]
|
|
62
|
+
started = [c for c in coded if (c.get("startdate") or 0) <= now + 7 * 86400]
|
|
63
|
+
terms = [t for c in started if (t := course_term(c))]
|
|
64
|
+
if terms:
|
|
65
|
+
cur = max(terms)
|
|
66
|
+
return [c for c in started if course_term(c) == cur]
|
|
67
|
+
return [c for c in started if (c.get("enddate") or now + 1) > now
|
|
68
|
+
and (c.get("startdate") or 0) > now - 200 * 86400]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def tracked_courses(con, courses: list[dict]) -> list[dict]:
|
|
72
|
+
"""跟踪的课;一门都没选过就自动选本学期的课(以后 `monash courses` 可以改)。"""
|
|
73
|
+
tracked = {r["id"] for r in con.execute("SELECT id FROM courses WHERE tracked=1")}
|
|
74
|
+
if not tracked:
|
|
75
|
+
picked = current_courses(courses)
|
|
76
|
+
con.executemany("UPDATE courses SET tracked=1 WHERE id=?", [(c["id"],) for c in picked])
|
|
77
|
+
con.commit()
|
|
78
|
+
return picked
|
|
79
|
+
return [c for c in courses if c["id"] in tracked]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# ---------------------------------------------------------------- 同步
|
|
83
|
+
|
|
84
|
+
def sync_moodle(log=print, only: list[dict] | None = None, dry_run: bool = False) -> dict:
|
|
85
|
+
client, con = MoodleClient(), db_connect()
|
|
86
|
+
courses = load_courses(client, con)
|
|
87
|
+
targets = only or tracked_courses(con, courses)
|
|
88
|
+
total = {"courses": [], "downloaded": 0, "bytes": 0, "removed": 0, "errors": 0}
|
|
89
|
+
for course in targets:
|
|
90
|
+
log(f"Moodle {course_code(course) or course_folder(course)}")
|
|
91
|
+
stats = CourseSync(client, con, course, dry_run=dry_run, log=log).run()
|
|
92
|
+
if not dry_run:
|
|
93
|
+
write_links_md(con, course["id"], course_folder(course))
|
|
94
|
+
total["courses"].append(course_code(course))
|
|
95
|
+
total["downloaded"] += stats.downloaded
|
|
96
|
+
total["bytes"] += stats.bytes
|
|
97
|
+
total["removed"] += stats.removed
|
|
98
|
+
total["errors"] += len(stats.errors)
|
|
99
|
+
if not dry_run:
|
|
100
|
+
set_state(con, "last_sync", {"at": now_iso(), "new_files": total["downloaded"]})
|
|
101
|
+
con.close()
|
|
102
|
+
return total
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def sync_ed(log=print) -> dict:
|
|
106
|
+
client = edlib.EdClient()
|
|
107
|
+
out = edsync.sync(log=log, client=client)
|
|
108
|
+
conn = edlib.db_connect()
|
|
109
|
+
for (cid,) in conn.execute("SELECT id FROM courses WHERE tracked=1").fetchall():
|
|
110
|
+
lessons.sync_lessons(conn, client, cid, log=log)
|
|
111
|
+
conn.close()
|
|
112
|
+
return out
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def have_ed_token() -> bool:
|
|
116
|
+
return bool(os.environ.get("ED_TOKEN")) or edlib.TOKEN_FILE.exists()
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def sync_all(log=print) -> dict:
|
|
120
|
+
"""能同步的都同步一遍。另一个进程正在同步就直接返回。"""
|
|
121
|
+
if not _lock():
|
|
122
|
+
log("另一个同步正在进行,这次跳过")
|
|
123
|
+
return read_status()
|
|
124
|
+
try:
|
|
125
|
+
status = read_status()
|
|
126
|
+
status["errors"] = {}
|
|
127
|
+
if have_ed_token():
|
|
128
|
+
try:
|
|
129
|
+
status["ed"] = {**sync_ed(log), "at": now_iso()}
|
|
130
|
+
except EdAuthError as e:
|
|
131
|
+
status["errors"]["ed"] = str(e)
|
|
132
|
+
except Exception as e: # noqa: BLE001 网络问题之类,下次再试
|
|
133
|
+
status["errors"]["ed"] = f"{type(e).__name__}: {e}"
|
|
134
|
+
else:
|
|
135
|
+
status["errors"]["ed"] = "还没设置 Ed 令牌(monash login ed)"
|
|
136
|
+
try:
|
|
137
|
+
status["moodle"] = {**sync_moodle(log), "at": now_iso()}
|
|
138
|
+
except MoodleAuthError as e:
|
|
139
|
+
status["errors"]["moodle"] = str(e)
|
|
140
|
+
except Exception as e: # noqa: BLE001
|
|
141
|
+
status["errors"]["moodle"] = f"{type(e).__name__}: {e}"
|
|
142
|
+
if load_settings().get("web_notes", True):
|
|
143
|
+
try:
|
|
144
|
+
from . import webnotes
|
|
145
|
+
con = db_connect()
|
|
146
|
+
status["web_notes"] = webnotes.sync(con, log)
|
|
147
|
+
con.close()
|
|
148
|
+
except Exception as e: # noqa: BLE001 讲义站抓不到不影响别的
|
|
149
|
+
status["errors"]["web_notes"] = f"{type(e).__name__}: {e}"
|
|
150
|
+
try:
|
|
151
|
+
con = db_connect()
|
|
152
|
+
try:
|
|
153
|
+
status["index"] = content_index.index(con)
|
|
154
|
+
finally:
|
|
155
|
+
con.close()
|
|
156
|
+
except Exception as e: # noqa: BLE001
|
|
157
|
+
status["errors"]["index"] = f"{type(e).__name__}: {e}"
|
|
158
|
+
status["last_sync"] = now_iso()
|
|
159
|
+
write_status(status)
|
|
160
|
+
return status
|
|
161
|
+
finally:
|
|
162
|
+
_unlock()
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def keepalive() -> int | None:
|
|
166
|
+
"""给 Moodle 会话续期一次(会话空闲 4 小时就过期)。返回剩余秒数。"""
|
|
167
|
+
left = MoodleClient().touch()
|
|
168
|
+
status = read_status()
|
|
169
|
+
status["session"] = {"alive": True, "checked_at": now_iso(), "time_remaining": left}
|
|
170
|
+
write_status(status)
|
|
171
|
+
return left
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# ---------------------------------------------------------------- 状态文件
|
|
175
|
+
|
|
176
|
+
def read_status() -> dict:
|
|
177
|
+
try:
|
|
178
|
+
return json.loads(STATUS_FILE.read_text(encoding="utf-8"))
|
|
179
|
+
except (OSError, ValueError):
|
|
180
|
+
return {}
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def write_status(status: dict) -> None:
|
|
184
|
+
HOME.mkdir(parents=True, exist_ok=True)
|
|
185
|
+
tmp = STATUS_FILE.with_suffix(".tmp")
|
|
186
|
+
tmp.write_text(json.dumps(status, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
187
|
+
os.replace(tmp, STATUS_FILE)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def last_sync_at(status: dict | None = None) -> str | None:
|
|
191
|
+
"""上次同步的时间。服务器上自己部署时课件是另一个脚本同步的,不写 status.json,
|
|
192
|
+
只在库里记 last_sync,所以 status.json 没有就看库里的。"""
|
|
193
|
+
at = (status or read_status()).get("last_sync")
|
|
194
|
+
if at:
|
|
195
|
+
return at
|
|
196
|
+
if not (DATA_DIR / "moodle.db").exists():
|
|
197
|
+
return None
|
|
198
|
+
try:
|
|
199
|
+
con = db_connect()
|
|
200
|
+
try:
|
|
201
|
+
return (get_state(con, "last_sync") or {}).get("at")
|
|
202
|
+
finally:
|
|
203
|
+
con.close()
|
|
204
|
+
except Exception: # noqa: BLE001
|
|
205
|
+
return None
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def hours_since_sync(status: dict | None = None) -> float:
|
|
209
|
+
at = last_sync_at(status)
|
|
210
|
+
if not at:
|
|
211
|
+
return float("inf")
|
|
212
|
+
return (datetime.now(timezone.utc) - datetime.fromisoformat(at)).total_seconds() / 3600
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _lock() -> bool:
|
|
216
|
+
"""跨进程的同步锁(CLI 和 MCP 可能同时想同步)。超过 2 小时的锁当成上次崩了留下的。"""
|
|
217
|
+
HOME.mkdir(parents=True, exist_ok=True)
|
|
218
|
+
try:
|
|
219
|
+
if time.time() - LOCK_FILE.stat().st_mtime > 7200:
|
|
220
|
+
LOCK_FILE.unlink(missing_ok=True)
|
|
221
|
+
except OSError:
|
|
222
|
+
pass
|
|
223
|
+
try:
|
|
224
|
+
fd = os.open(LOCK_FILE, os.O_WRONLY | os.O_CREAT | os.O_EXCL)
|
|
225
|
+
except FileExistsError:
|
|
226
|
+
return False
|
|
227
|
+
os.write(fd, str(os.getpid()).encode())
|
|
228
|
+
os.close(fd)
|
|
229
|
+
return True
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _unlock() -> None:
|
|
233
|
+
Path(LOCK_FILE).unlink(missing_ok=True)
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""整节读 Ed lesson:按页序把文字页、阅读网页、PDF 课件拼成一篇 Markdown,末尾附测验题。
|
|
2
|
+
|
|
3
|
+
适合“带我过一遍第 3 周的 pre-class”“这节讲了什么”。各部分都是同步时已经存在本地的:
|
|
4
|
+
* document / code 页 → lessons.py 存的 .md(ED_FILES_DIR)
|
|
5
|
+
* webpage 页 → webnotes.py 抓的课程笔记(FILES_DIR,按网址在 web_pages 表里找);
|
|
6
|
+
没抓的(需要登录的站、视频之类)只留一个链接
|
|
7
|
+
* pdf 页 → 下载好的 PDF,抽文字,标页码
|
|
8
|
+
不发任何网络请求。
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import sqlite3
|
|
13
|
+
|
|
14
|
+
from . import edquery, textextract
|
|
15
|
+
from .moodlelib import FILES_DIR
|
|
16
|
+
from .paths import ED_FILES_DIR
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def find_lesson(conn, course, lesson) -> dict:
|
|
20
|
+
"""lesson 写 id,或标题的一部分(如 "W3 Pre-Class")。匹配到好几节就列出来让人挑。"""
|
|
21
|
+
rows = edquery.lessons(conn, course)
|
|
22
|
+
ref = str(lesson).strip()
|
|
23
|
+
hits = [l for l in rows if str(l["id"]) == ref] or \
|
|
24
|
+
[l for l in rows if ref.lower() in (l["title"] or "").lower()]
|
|
25
|
+
if not hits:
|
|
26
|
+
titles = "、".join(l["title"] for l in rows[:12]) or "(这门课在 Ed 上没有 Lessons)"
|
|
27
|
+
raise edquery.NotFound(f"{course} 里没有 lesson “{lesson}”。有这些:{titles}")
|
|
28
|
+
exact = [l for l in hits if (l["title"] or "").lower() == ref.lower()]
|
|
29
|
+
if len(hits) > 1 and len(exact) != 1:
|
|
30
|
+
raise edquery.NotFound("匹配到好几节:" + ";".join(f"{l['id']} {l['title']}" for l in hits[:8])
|
|
31
|
+
+ "。写得更具体一点,或者用 id")
|
|
32
|
+
return exact[0] if len(hits) > 1 else hits[0]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _web_page(url: str) -> str | None:
|
|
36
|
+
"""课程笔记里抓好的那份网页(在 moodle.db 的 web_pages 表)。"""
|
|
37
|
+
from . import webnotes
|
|
38
|
+
from .moodlelib import db_connect
|
|
39
|
+
u = webnotes.normalize(url or "")
|
|
40
|
+
if not u:
|
|
41
|
+
return None
|
|
42
|
+
try:
|
|
43
|
+
con = db_connect()
|
|
44
|
+
row = con.execute("SELECT path FROM web_pages WHERE url=? AND path IS NOT NULL", (u,)).fetchone()
|
|
45
|
+
con.close()
|
|
46
|
+
except sqlite3.Error:
|
|
47
|
+
return None
|
|
48
|
+
if not row:
|
|
49
|
+
return None
|
|
50
|
+
try:
|
|
51
|
+
return (FILES_DIR / row["path"]).read_text(encoding="utf-8")
|
|
52
|
+
except OSError:
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _strip_header(text: str) -> str:
|
|
57
|
+
"""存下来的 .md 第一行是 “# 标题”,拼的时候标题我们自己写,去掉重复的。"""
|
|
58
|
+
lines = text.strip().splitlines()
|
|
59
|
+
return "\n".join(lines[1:]).strip() if lines and lines[0].startswith("# ") else text.strip()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def lesson_markdown(conn, course, lesson) -> dict:
|
|
63
|
+
l = find_lesson(conn, course, lesson) # edquery.lessons 里会顺带补上老库的 idx 列
|
|
64
|
+
rows = conn.execute("SELECT id, type, title, file_url, url, local_path FROM lesson_files WHERE lesson_id=? "
|
|
65
|
+
"ORDER BY COALESCE(idx, 1e9), id", (l["id"],)).fetchall()
|
|
66
|
+
parts, readable = [], 0
|
|
67
|
+
for r in rows:
|
|
68
|
+
title = r["title"] or r["type"]
|
|
69
|
+
if r["type"] in ("document", "code") and r["local_path"]:
|
|
70
|
+
try:
|
|
71
|
+
body = _strip_header((ED_FILES_DIR / r["local_path"]).read_text(encoding="utf-8"))
|
|
72
|
+
except OSError:
|
|
73
|
+
continue
|
|
74
|
+
parts.append(f"## {title}\n\n{body}")
|
|
75
|
+
readable += 1
|
|
76
|
+
elif r["type"] == "webpage":
|
|
77
|
+
page = _web_page(r["url"])
|
|
78
|
+
if page:
|
|
79
|
+
parts.append(f"## {title}\n\n{_strip_header(page)}")
|
|
80
|
+
readable += 1
|
|
81
|
+
else:
|
|
82
|
+
parts.append(f"## {title}\n\n[网页:{r['url']}](没有抓下来:可能要登录,或者还没同步到)")
|
|
83
|
+
elif r["type"] == "pdf" and r["local_path"]:
|
|
84
|
+
pages = textextract.pdf_pages(ED_FILES_DIR / r["local_path"])
|
|
85
|
+
text = "\n\n".join(f"[第 {i} 页] {' '.join(p.split())}" for i, p in enumerate(pages, 1) if p.strip())
|
|
86
|
+
parts.append(f"## {title}(PDF)\n\n{text or '(这个 PDF 抽不出文字,可能是图片)'}")
|
|
87
|
+
readable += bool(text)
|
|
88
|
+
quiz = [q for q in edquery.quizzes(conn, course) if q["url"] == l["url"]]
|
|
89
|
+
md = [f"# {l['title']}", f"{l['module'] or ''} · {l['status']} · {l['url']}", ""]
|
|
90
|
+
md.append("\n\n---\n\n".join(parts) if parts else "(这节没有能读的内容,可能全是视频或测验)")
|
|
91
|
+
if quiz:
|
|
92
|
+
md += ["", "---", "", edquery.quiz_markdown(quiz)]
|
|
93
|
+
return {"id": l["id"], "title": l["title"], "status": l["status"], "url": l["url"],
|
|
94
|
+
"readings": readable, "markdown": "\n".join(md).rstrip() + "\n"}
|