ntu-cool-cli 1.3.1__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.
- ntu_cool_cli-1.3.1.dist-info/METADATA +505 -0
- ntu_cool_cli-1.3.1.dist-info/RECORD +7 -0
- ntu_cool_cli-1.3.1.dist-info/WHEEL +5 -0
- ntu_cool_cli-1.3.1.dist-info/entry_points.txt +2 -0
- ntu_cool_cli-1.3.1.dist-info/licenses/LICENSE +21 -0
- ntu_cool_cli-1.3.1.dist-info/top_level.txt +1 -0
- ntu_fetch.py +1091 -0
ntu_fetch.py
ADDED
|
@@ -0,0 +1,1091 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Fetch NTU Cool course announcements as raw-first markdown.
|
|
3
|
+
|
|
4
|
+
Design constraints, each one earned:
|
|
5
|
+
|
|
6
|
+
* **No credential ever touches this file or a repo.** NTU_USER / NTU_PASS come from the
|
|
7
|
+
environment, or from a JSON file the caller names with --cred-file. Nothing is written
|
|
8
|
+
to disk by this script.
|
|
9
|
+
* **Robust JSON parse.** A fixed-offset `json.loads(r.text[9:])` — a hardcoded byte offset
|
|
10
|
+
with no documented reason. Against a 401 body it yields `:"unauthenticated",...` and a
|
|
11
|
+
JSONDecodeError. We anchor on the first `{` or `[` instead, so a prefix may exist or not.
|
|
12
|
+
* **Markdown output, not a database.** The digest pipeline already owns storage, dedupe,
|
|
13
|
+
and delivery. Writing SQLite would create a second system for a one-endpoint need.
|
|
14
|
+
* **Dry run before credentials.** --probe hits both platforms unauthenticated. If either
|
|
15
|
+
returns 404 the platform moved and auth debugging is wasted effort.
|
|
16
|
+
|
|
17
|
+
# no credentials, ever
|
|
18
|
+
python3 ntu_fetch.py --probe
|
|
19
|
+
|
|
20
|
+
# with credentials supplied by the caller
|
|
21
|
+
NTU_USER=... NTU_PASS=... python3 ntu_fetch.py \
|
|
22
|
+
--course <COURSE> --since 2026-10-01 --out ~/captures/
|
|
23
|
+
"""
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import argparse
|
|
27
|
+
import datetime
|
|
28
|
+
import json
|
|
29
|
+
import os
|
|
30
|
+
import re
|
|
31
|
+
import sys
|
|
32
|
+
|
|
33
|
+
# Both imports are declared, not assumed. An earlier version only guarded
|
|
34
|
+
# `requests` and let `bs4` fail deep inside cool_login with a ModuleNotFoundError
|
|
35
|
+
# traceback that looked like a code bug. That happened because it was only ever
|
|
36
|
+
# run under Hermes' own interpreter, which ships beautifulsoup4 — a different
|
|
37
|
+
# Python than the one a user types at a shell prompt.
|
|
38
|
+
def _can_import(name: str) -> bool:
|
|
39
|
+
import importlib.util
|
|
40
|
+
try:
|
|
41
|
+
return importlib.util.find_spec(name) is not None
|
|
42
|
+
except (ImportError, ValueError):
|
|
43
|
+
return False
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
_MISSING = [m for m in ("requests",) if not _can_import(m)]
|
|
47
|
+
if _MISSING:
|
|
48
|
+
sys.exit(
|
|
49
|
+
"missing required package(s): " + ", ".join(_MISSING) + "\n"
|
|
50
|
+
"install into the SAME interpreter you are running this with:\n"
|
|
51
|
+
" python3 -m pip install --user " + " ".join(_MISSING) + "\n"
|
|
52
|
+
"note: a venv's python and the system python have separate site-packages. "
|
|
53
|
+
"If a previous install seems to have done nothing, you are running a "
|
|
54
|
+
"different interpreter than the one you installed into.")
|
|
55
|
+
|
|
56
|
+
# bs4 is deliberately NOT a dependency. The two things this script needs from an
|
|
57
|
+
# HTML parser are "find a form, read its inputs" and "flatten the visible text",
|
|
58
|
+
# and html.parser (stdlib, always present) does both. Requiring bs4 meant the
|
|
59
|
+
# script only ran under an interpreter that happened to ship it — Hermes' own
|
|
60
|
+
# venv had it, the system python did not, and the failure surfaced as a
|
|
61
|
+
# ModuleNotFoundError traceback inside the login function. Zero third-party
|
|
62
|
+
# parsers is one fewer way for this to break on someone else's machine.
|
|
63
|
+
import requests
|
|
64
|
+
from html.parser import HTMLParser
|
|
65
|
+
from urllib.parse import urljoin, urlparse
|
|
66
|
+
from requests.adapters import HTTPAdapter
|
|
67
|
+
from urllib3.util.retry import Retry
|
|
68
|
+
|
|
69
|
+
COOL = "https://cool.ntu.edu.tw"
|
|
70
|
+
API = f"{COOL}/api/v1"
|
|
71
|
+
|
|
72
|
+
USER_AGENT = ("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
|
|
73
|
+
"(KHTML, like Gecko) Chrome/120 Safari/537.36")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# ---------------------------------------------------------------- credentials
|
|
77
|
+
|
|
78
|
+
def load_credentials(cred_file: str | None) -> tuple[str, str]:
|
|
79
|
+
"""Return (user, pass). Never logs, never stores, never echoes either value."""
|
|
80
|
+
if cred_file:
|
|
81
|
+
_assert_private_cred_file(cred_file)
|
|
82
|
+
with open(cred_file, encoding="utf-8") as f:
|
|
83
|
+
data = json.load(f)
|
|
84
|
+
return data.get("ntu_user", ""), data.get("ntu_pass", "")
|
|
85
|
+
return os.environ.get("NTU_USER", ""), os.environ.get("NTU_PASS", "")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _abs_action(form, base: str) -> str:
|
|
89
|
+
"""Resolve a <form action> against a known base. Accepts either a parsed
|
|
90
|
+
_FormOnly or anything with a .get("action") — the test doubles use the latter.
|
|
91
|
+
|
|
92
|
+
Never concatenate a form action onto a host: an absolute action would become
|
|
93
|
+
'https://adfs.ntu.edu.tw/https://evil.example/' and a missing one raises
|
|
94
|
+
MissingSchema with no hint about which hop broke. urljoin handles all three
|
|
95
|
+
cases, and the origin check turns a redirect-to-elsewhere into a named error
|
|
96
|
+
instead of silently posting NTU credentials somewhere unexpected.
|
|
97
|
+
"""
|
|
98
|
+
action = form.action if hasattr(form, "action") else form.get("action")
|
|
99
|
+
if not action:
|
|
100
|
+
raise RuntimeError(f"login form on {base} has no action attribute")
|
|
101
|
+
target = urljoin(base, action)
|
|
102
|
+
base_host = urlparse(base).netloc
|
|
103
|
+
if urlparse(target).netloc != base_host:
|
|
104
|
+
raise RuntimeError(
|
|
105
|
+
f"login form on {base} points off-origin ({target}); refusing to "
|
|
106
|
+
"send credentials there")
|
|
107
|
+
return target
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _session(retries: int = 2) -> requests.Session:
|
|
111
|
+
"""Session with backoff. The NTU login is credential-bearing: a tight retry
|
|
112
|
+
loop on a wrong password is how accounts get locked, so retries are few,
|
|
113
|
+
spaced, and only on transport errors and 5xx — never on 401.
|
|
114
|
+
|
|
115
|
+
The explicit User-Agent is load-bearing, not decoration. cool.ntu.edu.tw and
|
|
116
|
+
adfs.ntu.edu.tw sit behind a filter that resets the connection at the TCP
|
|
117
|
+
level when the UA contains `python-requests`. Measured 2026-10-21 on the
|
|
118
|
+
same host, same URL, same TLS stack, minutes apart:
|
|
119
|
+
UA 'curl/8.18.0' -> 200 (10847 bytes)
|
|
120
|
+
UA 'Python-urllib/3.12' -> 200
|
|
121
|
+
UA 'Mozilla/5.0' -> 200
|
|
122
|
+
UA 'python-requests/2.32.5' -> ConnectionResetError(104)
|
|
123
|
+
UA 'curl/8.18.0 python-requests' -> ConnectionResetError(104)
|
|
124
|
+
So it matches the token anywhere in the header, and retrying the default UA
|
|
125
|
+
cannot succeed — 8 consecutive attempts all failed with exit 0 from curl and
|
|
126
|
+
stdlib urllib in the same minute. Every hop of the SAML chain (cool, ADFS,
|
|
127
|
+
and the API base) goes through this session, so the UA must be set here
|
|
128
|
+
rather than per-request.
|
|
129
|
+
"""
|
|
130
|
+
s = requests.Session()
|
|
131
|
+
s.headers.update({"User-Agent": USER_AGENT})
|
|
132
|
+
retry = Retry(total=retries, backoff_factor=1.5,
|
|
133
|
+
status_forcelist=[502, 503, 504],
|
|
134
|
+
allowed_methods=frozenset(["GET"]))
|
|
135
|
+
s.mount("https://", HTTPAdapter(max_retries=retry))
|
|
136
|
+
return s
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _assert_private_cred_file(path: str) -> None:
|
|
140
|
+
"""A world-readable credential file is a leaked credential. Refuse it."""
|
|
141
|
+
mode = os.stat(path).st_mode & 0o077
|
|
142
|
+
if mode:
|
|
143
|
+
raise RuntimeError(
|
|
144
|
+
f"{path} is readable by others (mode {oct(os.stat(path).st_mode & 0o777)}). "
|
|
145
|
+
f"Run: chmod 600 {path}")
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
# ---------------------------------------------------------------- json parsing
|
|
149
|
+
|
|
150
|
+
def parse_json_loose(text: str):
|
|
151
|
+
"""Parse JSON that may carry a leading guard prefix.
|
|
152
|
+
|
|
153
|
+
A fixed 9-byte slice is an undocumented offset and it fails on any
|
|
154
|
+
body that does not happen to start with exactly those bytes. Anchoring on the first
|
|
155
|
+
structural character is correct whether or not a prefix exists.
|
|
156
|
+
"""
|
|
157
|
+
starts = [i for i in (text.find("{"), text.find("[")) if i != -1]
|
|
158
|
+
if not starts:
|
|
159
|
+
raise ValueError("no JSON object or array found in response body")
|
|
160
|
+
return json.loads(text[min(starts):])
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
# ---------------------------------------------------------------- login
|
|
164
|
+
|
|
165
|
+
def cool_login(s: requests.Session, user: str, password: str) -> None:
|
|
166
|
+
"""Walk the cool.ntu.edu.tw -> ADFS -> cool SAML chain.
|
|
167
|
+
|
|
168
|
+
Each hop parses the form actually served rather than assuming field names,
|
|
169
|
+
except the two ASP.NET credential fields which are stable.
|
|
170
|
+
"""
|
|
171
|
+
r1 = s.get(f"{COOL}/login/saml", timeout=20)
|
|
172
|
+
form1 = parse_first_form(r1.text)
|
|
173
|
+
if not form1.action:
|
|
174
|
+
raise RuntimeError("no login form at cool.ntu.edu.tw/login/saml")
|
|
175
|
+
|
|
176
|
+
data1 = dict(form1.inputs)
|
|
177
|
+
data1["ctl00$ContentPlaceHolder1$UsernameTextBox"] = user
|
|
178
|
+
data1["ctl00$ContentPlaceHolder1$PasswordTextBox"] = password
|
|
179
|
+
|
|
180
|
+
r2 = s.post(_abs_action(form1, "https://adfs.ntu.edu.tw/"),
|
|
181
|
+
data=data1, allow_redirects=True, timeout=20)
|
|
182
|
+
form2 = parse_first_form(r2.text)
|
|
183
|
+
if not form2.action:
|
|
184
|
+
raise RuntimeError("no ADFS form after submitting to adfs.ntu.edu.tw")
|
|
185
|
+
|
|
186
|
+
s.post(_abs_action(form2, COOL + "/"), data=dict(form2.inputs),
|
|
187
|
+
allow_redirects=True, timeout=20)
|
|
188
|
+
|
|
189
|
+
check = s.get(f"{API}/courses", timeout=20)
|
|
190
|
+
if check.status_code != 200:
|
|
191
|
+
# name the hop and the status; never the submitted values
|
|
192
|
+
raise RuntimeError(
|
|
193
|
+
f"login did not take — after the ADFS round trip, "
|
|
194
|
+
f"/api/v1/courses returned {check.status_code} (expected 200). "
|
|
195
|
+
"Either the SAML flow changed, or the credentials are wrong. "
|
|
196
|
+
"Nothing was written; nothing needs cleanup.")
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
# ---------------------------------------------------------------- fetching
|
|
200
|
+
|
|
201
|
+
def fetch_courses(s: requests.Session) -> list[dict]:
|
|
202
|
+
"""Current-term courses, parsed from the Cool home page.
|
|
203
|
+
|
|
204
|
+
NOT /api/v1/courses. That endpoint returns courses the account has EVER been
|
|
205
|
+
enrolled in — verified 2026-10-18, it answered with 10 courses whose newest
|
|
206
|
+
announcement was three months stale, omitted three current-term courses
|
|
207
|
+
entirely, and returned three dead ones as `{"access_restricted_by_date": true}`
|
|
208
|
+
with `name: null`. The current-term roster lives in JSON embedded in the home
|
|
209
|
+
page HTML, keyed by longName / href / term.
|
|
210
|
+
"""
|
|
211
|
+
r = s.get(COOL + "/", timeout=25)
|
|
212
|
+
r.raise_for_status()
|
|
213
|
+
hits = re.findall(
|
|
214
|
+
r'"longName":"([^"]+)".*?"href":"/courses/(\d+)".*?"term":("[^"]*"|null)',
|
|
215
|
+
r.text, re.S)
|
|
216
|
+
out, seen = [], set()
|
|
217
|
+
for long_name, cid, term in hits:
|
|
218
|
+
if cid in seen:
|
|
219
|
+
continue
|
|
220
|
+
seen.add(cid)
|
|
221
|
+
out.append({
|
|
222
|
+
"id": int(cid),
|
|
223
|
+
"name": long_name.split(" - ")[0],
|
|
224
|
+
"longName": long_name,
|
|
225
|
+
"courseCode": long_name.split(" - ")[-1] if " - " in long_name else "",
|
|
226
|
+
"term": None if term == "null" else term.strip('"'),
|
|
227
|
+
})
|
|
228
|
+
if not out:
|
|
229
|
+
raise RuntimeError(
|
|
230
|
+
"no courses parsed from the Cool home page. The embedded JSON shape "
|
|
231
|
+
"probably changed — /api/v1/courses is NOT a fallback here because it "
|
|
232
|
+
"returns historical enrolment, not the current roster.")
|
|
233
|
+
return out
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def fetch_all_courses_historical(s: requests.Session) -> list[dict]:
|
|
237
|
+
"""Every course the account has EVER been enrolled in. NOT the current
|
|
238
|
+
roster — see `fetch_courses`, which is what the CLI uses.
|
|
239
|
+
|
|
240
|
+
This endpoint is a trap. Measured 2026-10-23 on a real account: it answered
|
|
241
|
+
with 10 courses while the current roster had 7. The extras were last term's
|
|
242
|
+
(`【114-1 寒假】毒道之處`, `114 微積分學前自我檢測`, `工程力學一`,
|
|
243
|
+
`工程圖學`) plus three entries with `name: null` and
|
|
244
|
+
`access_restricted_by_date: true`. **Every one of them carried
|
|
245
|
+
`term: null`**, so there is no term field to filter on — you cannot tell
|
|
246
|
+
a stale course from a current one after the fact.
|
|
247
|
+
|
|
248
|
+
It is kept, unused, because reading last term's exam schedule is a real
|
|
249
|
+
need. It is NOT reachable from any CLI flag, and it should not be until
|
|
250
|
+
one exists that labels the output as historical. If you are wiring this
|
|
251
|
+
into something, filter by term yourself and say so in the output.
|
|
252
|
+
"""
|
|
253
|
+
r = s.get(f"{API}/courses", timeout=20)
|
|
254
|
+
r.raise_for_status()
|
|
255
|
+
return parse_json_loose(r.text)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def find_course(courses: list[dict], needle: str) -> dict | None:
|
|
259
|
+
"""Match on id or name, case-insensitive substring match."""
|
|
260
|
+
needle = needle.lower()
|
|
261
|
+
for c in courses:
|
|
262
|
+
if needle in str(c.get("id", "")).lower() or needle in str(c.get("name", "")).lower():
|
|
263
|
+
return c
|
|
264
|
+
return None
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def fetch_announcements(s: requests.Session, course_id) -> list[dict]:
|
|
268
|
+
return _paged_get(s, f"{API}/courses/{course_id}/discussion_topics",
|
|
269
|
+
params={"only_announcements": "true"}, timeout=30)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
# ---------------------------------------------------------------- course files
|
|
273
|
+
|
|
274
|
+
def _paged_get(s: requests.Session, url: str, params: dict | None = None,
|
|
275
|
+
timeout: int = 25) -> list:
|
|
276
|
+
"""Every page of a Canvas list endpoint, not just the first.
|
|
277
|
+
|
|
278
|
+
Canvas caps `per_page` and the bare call defaults to 10. Measured 2026-10-18:
|
|
279
|
+
one course's folder held 29 files and the unparameterised call returned 10 —
|
|
280
|
+
19 files silently missing, with no error and no empty-looking tail. 27 of 91
|
|
281
|
+
files across a term went missing that way.
|
|
282
|
+
|
|
283
|
+
Follow `Link: <...>; rel="next"` rather than assuming a page count. That is
|
|
284
|
+
the only pagination contract the API actually honours.
|
|
285
|
+
"""
|
|
286
|
+
out: list = []
|
|
287
|
+
p = dict(params or {})
|
|
288
|
+
p["per_page"] = 100
|
|
289
|
+
nxt: str | None = url
|
|
290
|
+
seen_pages = 0
|
|
291
|
+
while nxt:
|
|
292
|
+
seen_pages += 1
|
|
293
|
+
if seen_pages > 500: # runaway guard, not a limit we expect to hit
|
|
294
|
+
raise RuntimeError(f"pagination did not terminate at {url}")
|
|
295
|
+
r = s.get(nxt, params=p if nxt == url else None, timeout=timeout)
|
|
296
|
+
if r.status_code != 200:
|
|
297
|
+
break
|
|
298
|
+
chunk = parse_json_loose(r.text)
|
|
299
|
+
if not isinstance(chunk, list):
|
|
300
|
+
return chunk
|
|
301
|
+
out.extend(chunk)
|
|
302
|
+
p = {}
|
|
303
|
+
nxt = None
|
|
304
|
+
for part in r.headers.get("Link", "").split(","):
|
|
305
|
+
if 'rel="next"' in part:
|
|
306
|
+
nxt = part[part.find("<") + 1:part.find(">")] or None
|
|
307
|
+
return out
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def fetch_course_files(s: requests.Session, course_id) -> list[dict]:
|
|
311
|
+
"""Every file in the course's Files section, recursively.
|
|
312
|
+
|
|
313
|
+
The announcement body is NOT where course material lives. Measured on one
|
|
314
|
+
term: 20 announcement bodies carried 6,658 characters and zero file links,
|
|
315
|
+
while the Files tree held 64 files / 91 MB — including a worksheet that WAS
|
|
316
|
+
that term's next quiz scope. Reading announcements alone reports "no
|
|
317
|
+
attachments" and looks like nothing was missed.
|
|
318
|
+
|
|
319
|
+
Endpoint shape, established 2026-10-18 by probing:
|
|
320
|
+
/api/v1/courses/{id}/folders -> folder objects
|
|
321
|
+
folder["files_url"] -> files directly in that folder
|
|
322
|
+
folder["folders_url"] -> child folders
|
|
323
|
+
|
|
324
|
+
Do NOT guess these paths. `/api/v1/courses/{id}/files`,
|
|
325
|
+
`/api/v1/folders/{id}/subfolders`, `context_files`, and four other plausible
|
|
326
|
+
spellings all return 404 or 400. The folder objects carry the URLs, so read
|
|
327
|
+
them off the objects instead of composing them.
|
|
328
|
+
|
|
329
|
+
Returned dicts: folder, id, name, filename, size, content_type, url, uuid,
|
|
330
|
+
locked, created_at. `url` is a signed download link — treat it as a
|
|
331
|
+
credential-bearing string, never log it.
|
|
332
|
+
"""
|
|
333
|
+
out: list[dict] = []
|
|
334
|
+
seen_files: set = set()
|
|
335
|
+
seen_folders: set = set()
|
|
336
|
+
|
|
337
|
+
def walk(folder: dict) -> None:
|
|
338
|
+
fid = folder.get("id")
|
|
339
|
+
if fid in seen_folders:
|
|
340
|
+
return
|
|
341
|
+
seen_folders.add(fid)
|
|
342
|
+
|
|
343
|
+
for f in _paged_get(s, folder["files_url"]):
|
|
344
|
+
# Belt and braces: every folder is walked exactly once above, so this
|
|
345
|
+
# should never fire — but a folder reachable by two paths would cost
|
|
346
|
+
# a silent duplicate, and a duplicate reads as a real file.
|
|
347
|
+
if f.get("id") in seen_files:
|
|
348
|
+
continue
|
|
349
|
+
seen_files.add(f.get("id"))
|
|
350
|
+
out.append({
|
|
351
|
+
"folder": folder.get("full_name", ""),
|
|
352
|
+
"id": f.get("id"),
|
|
353
|
+
"name": f.get("display_name") or f.get("filename"),
|
|
354
|
+
"filename": f.get("filename"),
|
|
355
|
+
"size": f.get("size"),
|
|
356
|
+
"content_type": f.get("content-type"),
|
|
357
|
+
"url": f.get("url"),
|
|
358
|
+
"uuid": f.get("uuid"),
|
|
359
|
+
"locked": bool(f.get("locked")),
|
|
360
|
+
"created_at": f.get("created_at"),
|
|
361
|
+
})
|
|
362
|
+
for sub in _paged_get(s, folder["folders_url"]):
|
|
363
|
+
walk(sub)
|
|
364
|
+
|
|
365
|
+
# Entry point: the ROOT, not the flat folder list.
|
|
366
|
+
#
|
|
367
|
+
# GET /courses/{id}/folders returns a flat enumeration of folders at every
|
|
368
|
+
# depth, not a tree. Iterating it AND recursing folders_url walks each
|
|
369
|
+
# non-root folder twice (three times when its parent is listed too), which
|
|
370
|
+
# inflates the count — 110 where 64 are real. Walking only the root gives
|
|
371
|
+
# one visit per folder and nothing else has to be corrected afterwards.
|
|
372
|
+
roots: list[dict] = []
|
|
373
|
+
rr = s.get(f"{API}/courses/{course_id}/folders/root", timeout=25)
|
|
374
|
+
if rr.status_code == 200:
|
|
375
|
+
try:
|
|
376
|
+
payload = parse_json_loose(rr.text)
|
|
377
|
+
except Exception:
|
|
378
|
+
payload = None
|
|
379
|
+
# Only a dict is a folder. An empty body or a list here is not a root —
|
|
380
|
+
# treat it as absent and fall back rather than crashing in walk().
|
|
381
|
+
if isinstance(payload, dict) and payload.get("id") is not None:
|
|
382
|
+
roots = [payload]
|
|
383
|
+
if not roots:
|
|
384
|
+
# /folders/root answers 403 on at least one non-credit course
|
|
385
|
+
# ("使用者無權執行該操作") while the list endpoint still works. Fall back to
|
|
386
|
+
# the list and keep only the real roots — those with no parent.
|
|
387
|
+
flat = _paged_get(s, f"{API}/courses/{course_id}/folders")
|
|
388
|
+
roots = [f for f in flat if f.get("parent_folder_id") is None]
|
|
389
|
+
if not roots:
|
|
390
|
+
raise RuntimeError(
|
|
391
|
+
f"course {course_id}: no root folder reachable — /folders/root did not "
|
|
392
|
+
f"answer and /folders listed no parentless folder. Announcements may "
|
|
393
|
+
f"still be fetchable.")
|
|
394
|
+
for root in roots:
|
|
395
|
+
walk(root)
|
|
396
|
+
return out
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def fetch_assignments(s: requests.Session, course_id) -> list[dict]:
|
|
400
|
+
"""The homework roster for a course.
|
|
401
|
+
|
|
402
|
+
`/assignments` is the assignment list — the syllabus is not. Each entry
|
|
403
|
+
carries `due_at`, `lock_at`, `points_possible`, and (in the description) a
|
|
404
|
+
`data-api-endpoint` link to the handout, which is where CA material lives
|
|
405
|
+
when the Files tree is empty.
|
|
406
|
+
"""
|
|
407
|
+
return _paged_get(s, f"{API}/courses/{course_id}/assignments",
|
|
408
|
+
params={"per_page": 100})
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def fetch_own_submission(s: requests.Session, course_id, assignment_id) -> dict | None:
|
|
412
|
+
"""Your own submission for one assignment, or None if never handed in.
|
|
413
|
+
|
|
414
|
+
The path matters: `/submissions/self` returns 200, while the list form
|
|
415
|
+
`/submissions?self=1` returns 403 on every assignment. Same session, same
|
|
416
|
+
credentials — only the path differs.
|
|
417
|
+
|
|
418
|
+
Fields worth reading: `workflow_state` (unsubmitted / submitted / graded),
|
|
419
|
+
`submitted_at` (UTC), `score`, `late_policy_status`, and the assignment's
|
|
420
|
+
own `due_at` / `lock_at`. A `lock_at` in the past means the upload window
|
|
421
|
+
is closed — `seconds_late` keeps counting on an unsubmitted assignment and
|
|
422
|
+
is not evidence that you can still turn it in.
|
|
423
|
+
"""
|
|
424
|
+
r = s.get(f"{API}/courses/{course_id}/assignments/{assignment_id}/submissions/self",
|
|
425
|
+
timeout=30)
|
|
426
|
+
if r.status_code == 404:
|
|
427
|
+
return None
|
|
428
|
+
r.raise_for_status()
|
|
429
|
+
try:
|
|
430
|
+
return parse_json_loose(r.text)
|
|
431
|
+
except ValueError:
|
|
432
|
+
return None
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def fetch_course_pages(s: requests.Session, course_id) -> list[dict]:
|
|
436
|
+
"""Course pages — the syllabus, the calendar, the late-submission policy.
|
|
437
|
+
|
|
438
|
+
Pages are addressed by slug, and the slug lives on the module item
|
|
439
|
+
(`page_url`), not on the page object itself.
|
|
440
|
+
|
|
441
|
+
This reads the modules rather than the pages endpoint: the module list is
|
|
442
|
+
where the page items live, and it only carries them when
|
|
443
|
+
`include[]=items` is asked for. Reading the modules without that parameter
|
|
444
|
+
returns every `items` array empty — no error, just nothing.
|
|
445
|
+
"""
|
|
446
|
+
out: list[dict] = []
|
|
447
|
+
for m in fetch_modules(s, course_id):
|
|
448
|
+
for item in (m.get("items") or []):
|
|
449
|
+
if item.get("type") == "Page" and item.get("page_url"):
|
|
450
|
+
out.append(item)
|
|
451
|
+
return out
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def _absolute_endpoint(endpoint: str, course_id) -> str:
|
|
455
|
+
"""Absolute-ise a `data-api-endpoint` value.
|
|
456
|
+
|
|
457
|
+
Measured 2026-10-23: the attribute usually holds an absolute API URL, but
|
|
458
|
+
some assignments carry a bare page slug (`ke-cheng-da-gang`) instead.
|
|
459
|
+
Passing that to requests raises MissingSchema — the slug is not a URL.
|
|
460
|
+
"""
|
|
461
|
+
if endpoint.startswith("http"):
|
|
462
|
+
return endpoint
|
|
463
|
+
if "/" in endpoint:
|
|
464
|
+
return f"{API}/courses/{course_id}/{endpoint.lstrip('/')}"
|
|
465
|
+
return f"{API}/courses/{course_id}/pages/{endpoint}"
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def fetch_course_page(s: requests.Session, course_id, slug: str) -> dict:
|
|
469
|
+
"""One course page's content, addressed by its slug.
|
|
470
|
+
|
|
471
|
+
`/pages` is not a list endpoint — a page is fetched by slug, and the slug
|
|
472
|
+
comes from the module item (`page_url`). The body is HTML; `strip_html`
|
|
473
|
+
turns it into readable text.
|
|
474
|
+
"""
|
|
475
|
+
r = s.get(f"{API}/courses/{course_id}/pages/{slug}", timeout=30)
|
|
476
|
+
r.raise_for_status()
|
|
477
|
+
return parse_json_loose(r.text)
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def fetch_handout_bytes(s: requests.Session, endpoint: str, dest: str,
|
|
481
|
+
expect_magic: bytes | None = None) -> dict:
|
|
482
|
+
"""Download a handout that was linked from an assignment description.
|
|
483
|
+
|
|
484
|
+
Two hops, and the first one looks like a failure if you are not expecting
|
|
485
|
+
it: the `data-api-endpoint` answers with **JSON describing the file**, not
|
|
486
|
+
the file. The signed `url` is inside that JSON. A direct download against
|
|
487
|
+
the endpoint fails the magic-byte check with a misleading "magic
|
|
488
|
+
mismatch" — the wrong endpoint, not a corrupt file.
|
|
489
|
+
"""
|
|
490
|
+
r = s.get(endpoint, timeout=30)
|
|
491
|
+
r.raise_for_status()
|
|
492
|
+
meta = parse_json_loose(r.text)
|
|
493
|
+
signed = meta.get("url")
|
|
494
|
+
if not signed:
|
|
495
|
+
return {"error": "the file metadata carried no download url",
|
|
496
|
+
"status": r.status_code}
|
|
497
|
+
magic = expect_magic
|
|
498
|
+
if magic is None and "pdf" in (meta.get("content-type") or ""):
|
|
499
|
+
magic = b"%PDF-"
|
|
500
|
+
return download_file(s, signed, dest, magic)
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def fetch_modules(s: requests.Session, course_id) -> list[dict]:
|
|
504
|
+
"""Modules — lectures grouped by week, and the pages hanging off them.
|
|
505
|
+
|
|
506
|
+
`include[]=items` is required: without it the module list comes back with
|
|
507
|
+
every `items` array empty, and a caller that reads `items` sees nothing at
|
|
508
|
+
all — no error, just a blank list.
|
|
509
|
+
"""
|
|
510
|
+
return _paged_get(s, f"{API}/courses/{course_id}/modules",
|
|
511
|
+
params={"per_page": 100, "include[]": "items"})
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def fetch_assignment_handouts(s: requests.Session, course_id) -> list[dict]:
|
|
515
|
+
"""Handout links that live in assignment descriptions rather than the Files tree.
|
|
516
|
+
|
|
517
|
+
The description is HTML and the file id sits in a `data-api-endpoint`
|
|
518
|
+
attribute, whose href ends in `/download` rather than a file extension —
|
|
519
|
+
so the attachment grep does not find it. The endpoint answers with JSON
|
|
520
|
+
describing the file, not the bytes; the signed `url` is inside that JSON.
|
|
521
|
+
"""
|
|
522
|
+
out: list[dict] = []
|
|
523
|
+
for a in fetch_assignments(s, course_id):
|
|
524
|
+
for m in re.finditer(r'data-api-endpoint="([^"]+)"',
|
|
525
|
+
a.get("description") or ""):
|
|
526
|
+
out.append({
|
|
527
|
+
"assignment_id": a.get("id"),
|
|
528
|
+
"assignment_name": a.get("name"),
|
|
529
|
+
"endpoint": m.group(1),
|
|
530
|
+
})
|
|
531
|
+
return out
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def fetch_announcement_attachments(s: requests.Session, topics: list[dict]) -> list[dict]:
|
|
535
|
+
"""Files attached directly to announcements (a smaller, separate set).
|
|
536
|
+
|
|
537
|
+
These arrive in each topic's `attachments` array, already carrying a signed
|
|
538
|
+
`url`. Distinct from fetch_course_files: one term had 5 of these
|
|
539
|
+
against 110 in the Files tree.
|
|
540
|
+
"""
|
|
541
|
+
out = []
|
|
542
|
+
for t in topics:
|
|
543
|
+
for a in (t.get("attachments") or []):
|
|
544
|
+
out.append({
|
|
545
|
+
"folder": f"announcement:{t.get('title', '')}",
|
|
546
|
+
"id": a.get("id"),
|
|
547
|
+
"name": a.get("display_name") or a.get("filename"),
|
|
548
|
+
"filename": a.get("filename"),
|
|
549
|
+
"size": a.get("size"),
|
|
550
|
+
"content_type": a.get("content-type"),
|
|
551
|
+
"url": a.get("url"),
|
|
552
|
+
"uuid": a.get("uuid"),
|
|
553
|
+
"locked": bool(a.get("locked")),
|
|
554
|
+
"created_at": a.get("created_at"),
|
|
555
|
+
})
|
|
556
|
+
return out
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def download_file(s: requests.Session, url: str, dest: str,
|
|
560
|
+
expect_magic: bytes | None = None) -> dict:
|
|
561
|
+
"""Download one course file. Verifies it is not an HTML login page.
|
|
562
|
+
|
|
563
|
+
Cool answers an expired or unauthorised signed URL with a 200 that carries
|
|
564
|
+
the login page — so status alone proves nothing. Sniff the magic bytes.
|
|
565
|
+
"""
|
|
566
|
+
r = s.get(url, timeout=120)
|
|
567
|
+
result = {"status": r.status_code, "bytes": len(r.content),
|
|
568
|
+
"content_type": r.headers.get("Content-Type", "")}
|
|
569
|
+
if r.status_code != 200:
|
|
570
|
+
return result
|
|
571
|
+
body = r.content
|
|
572
|
+
if b"<!DOCTYPE html" in body[:400] or b"<html" in body[:200].lower():
|
|
573
|
+
result["error"] = "received an HTML page, not the file (session expired?)"
|
|
574
|
+
return result
|
|
575
|
+
if expect_magic and not body.startswith(expect_magic):
|
|
576
|
+
result["error"] = f"magic mismatch: expected {expect_magic!r}"
|
|
577
|
+
return result
|
|
578
|
+
os.makedirs(os.path.dirname(os.path.abspath(dest)) or ".", exist_ok=True)
|
|
579
|
+
with open(dest, "wb") as f:
|
|
580
|
+
f.write(body)
|
|
581
|
+
result["path"] = dest
|
|
582
|
+
return result
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
# ---------------------------------------------------------------- rendering
|
|
586
|
+
|
|
587
|
+
class _TextOnly(HTMLParser):
|
|
588
|
+
"""Collect visible text, dropping script/style content and collapsing runs
|
|
589
|
+
of whitespace. Replaces BeautifulSoup(...).stripped_strings for the one
|
|
590
|
+
thing this script needs it for."""
|
|
591
|
+
|
|
592
|
+
SKIP = {"script", "style", "head"}
|
|
593
|
+
|
|
594
|
+
def __init__(self):
|
|
595
|
+
super().__init__(convert_charrefs=True)
|
|
596
|
+
self.parts: list[str] = []
|
|
597
|
+
self._skipping = 0
|
|
598
|
+
|
|
599
|
+
def handle_starttag(self, tag, attrs):
|
|
600
|
+
if tag in self.SKIP:
|
|
601
|
+
self._skipping += 1
|
|
602
|
+
|
|
603
|
+
def handle_endtag(self, tag):
|
|
604
|
+
if tag in self.SKIP and self._skipping:
|
|
605
|
+
self._skipping -= 1
|
|
606
|
+
|
|
607
|
+
def handle_data(self, data):
|
|
608
|
+
if not self._skipping and data.strip():
|
|
609
|
+
self.parts.append(data.strip())
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
class _FormOnly(HTMLParser):
|
|
613
|
+
"""Capture the first <form>'s action plus every input's name/value.
|
|
614
|
+
|
|
615
|
+
"First" means first-in-document-order, matching BeautifulSoup's
|
|
616
|
+
`find("form")` — which is what most parsers reach for. Login pages commonly
|
|
617
|
+
park a hidden trap form early in <head>; waiting for a form to *close* picks
|
|
618
|
+
that one instead of the real login form, so capture on opening tag and stop
|
|
619
|
+
collecting once a second form starts.
|
|
620
|
+
"""
|
|
621
|
+
|
|
622
|
+
def __init__(self):
|
|
623
|
+
super().__init__(convert_charrefs=True)
|
|
624
|
+
self.action: str | None = None
|
|
625
|
+
self.inputs: dict[str, str] = {}
|
|
626
|
+
self._in_first = False
|
|
627
|
+
self._seen_form = False
|
|
628
|
+
|
|
629
|
+
def handle_starttag(self, tag, attrs):
|
|
630
|
+
a = dict(attrs)
|
|
631
|
+
if tag == "form":
|
|
632
|
+
if self._seen_form:
|
|
633
|
+
self._in_first = False # a later form — stop collecting
|
|
634
|
+
else:
|
|
635
|
+
self._seen_form = True
|
|
636
|
+
self._in_first = True
|
|
637
|
+
self.action = a.get("action")
|
|
638
|
+
elif tag == "input" and self._in_first and a.get("name"):
|
|
639
|
+
self.inputs[a["name"]] = a.get("value") or ""
|
|
640
|
+
|
|
641
|
+
def handle_endtag(self, tag):
|
|
642
|
+
if tag == "form" and self._in_first:
|
|
643
|
+
self._in_first = False
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
def strip_html(raw: str) -> str:
|
|
647
|
+
if not raw:
|
|
648
|
+
return ""
|
|
649
|
+
p = _TextOnly()
|
|
650
|
+
p.feed(raw)
|
|
651
|
+
p.close()
|
|
652
|
+
return "\n".join(p.parts)
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def parse_first_form(raw: str) -> _FormOnly:
|
|
656
|
+
p = _FormOnly()
|
|
657
|
+
p.feed(raw)
|
|
658
|
+
p.close()
|
|
659
|
+
return p
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
def find_attachments(raw: str, base: str = COOL + "/") -> list[str]:
|
|
663
|
+
"""PDF/attachment links — recorded, never downloaded automatically.
|
|
664
|
+
|
|
665
|
+
Relative hrefs are resolved against cool.ntu.edu.tw; recording '/courses/1/x.pdf'
|
|
666
|
+
raw is worse than useless because it looks like a link and is not one.
|
|
667
|
+
"""
|
|
668
|
+
found = re.findall(r'href="([^"]+\.(?:pdf|docx?|xlsx?|zip))"', raw or "", re.I)
|
|
669
|
+
return [urljoin(base, u) for u in found]
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
def render(since: datetime.date | None, annos: list[tuple[str, dict]]) -> str:
|
|
673
|
+
out = [
|
|
674
|
+
"---",
|
|
675
|
+
f"source: NTU Cool announcements (fetched {datetime.date.today()})",
|
|
676
|
+
"type: raw-capture",
|
|
677
|
+
f"count: {len(annos)}",
|
|
678
|
+
"---",
|
|
679
|
+
"",
|
|
680
|
+
"# NTU Cool 課程公告",
|
|
681
|
+
"",
|
|
682
|
+
]
|
|
683
|
+
if since:
|
|
684
|
+
out.append(f"篩選: {since} 之後發布")
|
|
685
|
+
out.append("")
|
|
686
|
+
|
|
687
|
+
for classname, a in annos:
|
|
688
|
+
raw = a.get("message", "")
|
|
689
|
+
body = strip_html(raw)
|
|
690
|
+
atts = find_attachments(raw)
|
|
691
|
+
out.append(f"## {a.get('title', '(無標題)')}")
|
|
692
|
+
out.append("")
|
|
693
|
+
out.append(f"- 課程: {classname}")
|
|
694
|
+
out.append(f"- 發布: {a.get('posted_at', '?')}")
|
|
695
|
+
out.append(f"- 連結: {a.get('url', '')}")
|
|
696
|
+
if atts:
|
|
697
|
+
out.append(f"- **附件 {len(atts)} 個(未下載,需另行處理)**:")
|
|
698
|
+
for u in atts:
|
|
699
|
+
out.append(f" - {u}")
|
|
700
|
+
out.append("")
|
|
701
|
+
out.append(body)
|
|
702
|
+
out.append("")
|
|
703
|
+
out.append("---")
|
|
704
|
+
out.append("")
|
|
705
|
+
return "\n".join(out)
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
# ---------------------------------------------------------------- modes
|
|
709
|
+
|
|
710
|
+
# Ceiba is blocked from this host: DNS resolves, three sibling NTU hosts answer in
|
|
711
|
+
# <150ms, ceiba times out on connect. See references/ceiba-unreachable.md. It is a
|
|
712
|
+
# known baseline, NOT a regression — so it must not turn a probe into a failure verdict.
|
|
713
|
+
KNOWN_UNREACHABLE = {"ceiba.ntu.edu.tw": "known blocked from this host (see reference)"}
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
def probe() -> int:
|
|
717
|
+
"""Unauthenticated reachability check. Run this before any credentialed call."""
|
|
718
|
+
targets = [
|
|
719
|
+
("NTU Cool /api/v1/courses", f"{API}/courses"),
|
|
720
|
+
("NTU Cool /login/saml", f"{COOL}/login/saml"),
|
|
721
|
+
("NTU Ceiba /", "https://ceiba.ntu.edu.tw/"),
|
|
722
|
+
]
|
|
723
|
+
print("unauthenticated probe\n")
|
|
724
|
+
critical_ok = True
|
|
725
|
+
for name, url in targets:
|
|
726
|
+
host = url.split("/")[2]
|
|
727
|
+
try:
|
|
728
|
+
r = _session().get(url, timeout=15,
|
|
729
|
+
headers={"User-Agent": USER_AGENT})
|
|
730
|
+
note = ""
|
|
731
|
+
if r.status_code == 401:
|
|
732
|
+
note = " <- alive, needs auth (good)"
|
|
733
|
+
elif r.status_code == 404:
|
|
734
|
+
note = " <- MOVED/DEAD"
|
|
735
|
+
critical_ok = False
|
|
736
|
+
elif r.status_code >= 500:
|
|
737
|
+
note = " <- platform error"
|
|
738
|
+
critical_ok = False
|
|
739
|
+
print(f" {name:28} HTTP {r.status_code}{note}")
|
|
740
|
+
except Exception as e:
|
|
741
|
+
# one dead target must not abort the whole probe — that is the
|
|
742
|
+
# difference between a diagnostic and a single point of failure
|
|
743
|
+
msg = str(e)
|
|
744
|
+
if host in KNOWN_UNREACHABLE:
|
|
745
|
+
print(f" {name:28} UNREACHABLE <- {KNOWN_UNREACHABLE[host]}")
|
|
746
|
+
else:
|
|
747
|
+
critical_ok = False
|
|
748
|
+
# ConnectionError from urllib3 stacks carry a 2000-char traceback
|
|
749
|
+
# tail that buries the actual cause
|
|
750
|
+
brief = msg.split("Max retries exceeded")[0].strip()
|
|
751
|
+
print(f" {name:28} FAIL {type(e).__name__}: {brief[:90]}")
|
|
752
|
+
|
|
753
|
+
if critical_ok:
|
|
754
|
+
print("\nNTU Cool reachable — safe to proceed with credentials.")
|
|
755
|
+
print("Ceiba unavailable from this host is expected; use the Cool path.")
|
|
756
|
+
else:
|
|
757
|
+
print("\nA reachable endpoint moved or failed. Do not debug credentials yet — "
|
|
758
|
+
"fix reachability first.")
|
|
759
|
+
return 0 if critical_ok else 1
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
def main() -> int:
|
|
763
|
+
ap = argparse.ArgumentParser(description=__doc__,
|
|
764
|
+
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
765
|
+
ap.add_argument("--probe", action="store_true",
|
|
766
|
+
help="unauthenticated reachability check, no credentials used")
|
|
767
|
+
ap.add_argument("--course", action="append", default=[],
|
|
768
|
+
help="course id or name substring; repeatable")
|
|
769
|
+
ap.add_argument("--all-courses", action="store_true",
|
|
770
|
+
help="fetch every enrolled course")
|
|
771
|
+
ap.add_argument("--since", help="YYYY-MM-DD, filter announcements older than this")
|
|
772
|
+
ap.add_argument("--out", help="output directory; filename is generated")
|
|
773
|
+
ap.add_argument("--cred-file", help="JSON with ntu_user / ntu_pass keys")
|
|
774
|
+
ap.add_argument("--stdout", action="store_true", help="print instead of writing")
|
|
775
|
+
ap.add_argument("--list-files", action="store_true",
|
|
776
|
+
help="list course FILES (the Files section), not announcements")
|
|
777
|
+
ap.add_argument("--files-out", help="directory for downloaded files")
|
|
778
|
+
ap.add_argument("--download-files", action="store_true",
|
|
779
|
+
help="download every file in the Files section")
|
|
780
|
+
ap.add_argument("--max-mb", type=float, default=0.0,
|
|
781
|
+
help="with --download-files: skip files larger than this")
|
|
782
|
+
ap.add_argument("--list-assignments", action="store_true",
|
|
783
|
+
help="list the homework roster (/assignments): name, due, lock, points")
|
|
784
|
+
ap.add_argument("--list-submissions", action="store_true",
|
|
785
|
+
help="list your own submission status per assignment (read-only)")
|
|
786
|
+
ap.add_argument("--list-pages", action="store_true",
|
|
787
|
+
help="list course pages: syllabus, calendar, late-submission policy")
|
|
788
|
+
ap.add_argument("--list-modules", action="store_true",
|
|
789
|
+
help="list modules — lectures grouped by week")
|
|
790
|
+
ap.add_argument("--list-handouts", action="store_true",
|
|
791
|
+
help="list handout links found in assignment descriptions "
|
|
792
|
+
"(where CA material lives when the Files tree is empty)")
|
|
793
|
+
ap.add_argument("--show-page", metavar="SLUG",
|
|
794
|
+
help="print one course page's content, addressed by its "
|
|
795
|
+
"slug (see --list-pages for the slugs)")
|
|
796
|
+
ap.add_argument("--download-handout", action="store_true",
|
|
797
|
+
help="download the handouts linked in assignment descriptions")
|
|
798
|
+
args = ap.parse_args()
|
|
799
|
+
|
|
800
|
+
if args.probe:
|
|
801
|
+
return probe()
|
|
802
|
+
|
|
803
|
+
if not args.course and not args.all_courses:
|
|
804
|
+
ap.error("need --course (repeatable) or --all-courses, or use --probe")
|
|
805
|
+
|
|
806
|
+
user, password = load_credentials(args.cred_file)
|
|
807
|
+
if not user or not password:
|
|
808
|
+
ap.error("no credentials. Set NTU_USER and NTU_PASS in the environment, "
|
|
809
|
+
"or pass --cred-file. (Hermes does not supply these.)")
|
|
810
|
+
|
|
811
|
+
since = None
|
|
812
|
+
if args.since:
|
|
813
|
+
try:
|
|
814
|
+
since = datetime.date.fromisoformat(args.since)
|
|
815
|
+
except ValueError:
|
|
816
|
+
ap.error(f"--since must be YYYY-MM-DD, got {args.since!r}")
|
|
817
|
+
|
|
818
|
+
with _session() as s:
|
|
819
|
+
s.headers.update({"User-Agent": USER_AGENT})
|
|
820
|
+
cool_login(s, user, password)
|
|
821
|
+
print("login OK", file=sys.stderr)
|
|
822
|
+
|
|
823
|
+
courses = fetch_courses(s)
|
|
824
|
+
|
|
825
|
+
# ---- one course page's content, by slug ----
|
|
826
|
+
if args.show_page:
|
|
827
|
+
picked = courses if args.all_courses else [
|
|
828
|
+
c for c in courses
|
|
829
|
+
if any(find_course([c], n) for n in args.course)]
|
|
830
|
+
if not picked:
|
|
831
|
+
ap.error("no course matched --course")
|
|
832
|
+
if len(picked) > 1:
|
|
833
|
+
ap.error(f"--show-page needs one course, but {len(picked)} matched; "
|
|
834
|
+
f"narrow it with --course")
|
|
835
|
+
c = picked[0]
|
|
836
|
+
try:
|
|
837
|
+
page = fetch_course_page(s, c["id"], args.show_page)
|
|
838
|
+
except Exception as e:
|
|
839
|
+
print(f" FAIL {type(e).__name__}: {e}", file=sys.stderr)
|
|
840
|
+
return 1
|
|
841
|
+
body = strip_html(page.get("body") or "")
|
|
842
|
+
title = page.get("title") or args.show_page
|
|
843
|
+
print(f"# {title}\n")
|
|
844
|
+
print(f"course: {c.get('name')} ({c['id']})")
|
|
845
|
+
print(f"slug : {args.show_page}")
|
|
846
|
+
print(f"url : {page.get('html_url') or ''}")
|
|
847
|
+
print(f"updated: {page.get('updated_at') or ''}\n")
|
|
848
|
+
print(body)
|
|
849
|
+
return 0
|
|
850
|
+
|
|
851
|
+
# ---- course-scoped list modes, all read-only ----
|
|
852
|
+
if (args.list_assignments or args.list_submissions or args.list_pages
|
|
853
|
+
or args.list_modules or args.list_handouts
|
|
854
|
+
or args.download_handout):
|
|
855
|
+
picked = courses if args.all_courses else [
|
|
856
|
+
c for c in courses
|
|
857
|
+
if any(find_course([c], n) for n in args.course)]
|
|
858
|
+
if not picked:
|
|
859
|
+
ap.error("no course matched --course")
|
|
860
|
+
for c in picked:
|
|
861
|
+
cname = c.get("name") or str(c.get("id"))
|
|
862
|
+
cid = c.get("id")
|
|
863
|
+
if args.list_assignments or args.list_submissions:
|
|
864
|
+
try:
|
|
865
|
+
assigns = fetch_assignments(s, cid)
|
|
866
|
+
except Exception as e:
|
|
867
|
+
print(f"{str(cname)[:30]:32} FAIL {type(e).__name__}: {e}",
|
|
868
|
+
file=sys.stderr)
|
|
869
|
+
continue
|
|
870
|
+
for a in assigns:
|
|
871
|
+
due = a.get("due_at") or ""
|
|
872
|
+
lock = a.get("lock_at") or ""
|
|
873
|
+
pts = a.get("points_possible")
|
|
874
|
+
line = (f" [{a.get('id')}] {str(a.get('name'))[:44]:46} "
|
|
875
|
+
f"due {due[:16]:18} lock {lock[:16]:18}")
|
|
876
|
+
if pts is not None:
|
|
877
|
+
line += f" {pts} pts"
|
|
878
|
+
if args.list_submissions:
|
|
879
|
+
sub = fetch_own_submission(s, cid, a.get("id"))
|
|
880
|
+
if sub is None:
|
|
881
|
+
state = "not-found"
|
|
882
|
+
else:
|
|
883
|
+
state = str(sub.get("workflow_state") or "?")
|
|
884
|
+
if sub.get("score") is not None:
|
|
885
|
+
state += f" score={sub['score']}"
|
|
886
|
+
if sub.get("late_policy_status"):
|
|
887
|
+
state += f" {sub['late_policy_status']}"
|
|
888
|
+
line += f" [{state}]"
|
|
889
|
+
print(line)
|
|
890
|
+
if args.list_modules:
|
|
891
|
+
try:
|
|
892
|
+
mods = fetch_modules(s, cid)
|
|
893
|
+
except Exception as e:
|
|
894
|
+
print(f" modules FAIL {type(e).__name__}: {e}", file=sys.stderr)
|
|
895
|
+
continue
|
|
896
|
+
for m in mods:
|
|
897
|
+
print(f" module {m.get('id')} {str(m.get('name'))[:50]}")
|
|
898
|
+
for it in (m.get("items") or []):
|
|
899
|
+
print(f" {str(it.get('type')):12} "
|
|
900
|
+
f"{str(it.get('title') or it.get('page_url'))[:50]}")
|
|
901
|
+
if args.list_pages:
|
|
902
|
+
try:
|
|
903
|
+
pages = fetch_course_pages(s, cid)
|
|
904
|
+
except Exception as e:
|
|
905
|
+
print(f" pages FAIL {type(e).__name__}: {e}", file=sys.stderr)
|
|
906
|
+
continue
|
|
907
|
+
for p in pages:
|
|
908
|
+
print(f" page {str(p.get('page_url'))[:60]:62} "
|
|
909
|
+
f"{str(p.get('title'))[:40]}")
|
|
910
|
+
if args.list_handouts or args.download_handout:
|
|
911
|
+
try:
|
|
912
|
+
handouts = fetch_assignment_handouts(s, cid)
|
|
913
|
+
except Exception as e:
|
|
914
|
+
print(f" handouts FAIL {type(e).__name__}: {e}", file=sys.stderr)
|
|
915
|
+
continue
|
|
916
|
+
for h in handouts:
|
|
917
|
+
endpoint = _absolute_endpoint(h["endpoint"], cid)
|
|
918
|
+
is_page = "/pages/" in endpoint
|
|
919
|
+
print(f" [{h.get('assignment_id')}] "
|
|
920
|
+
f"{str(h.get('assignment_name'))[:40]:42} {endpoint}")
|
|
921
|
+
if not args.download_handout:
|
|
922
|
+
continue
|
|
923
|
+
if not args.files_out:
|
|
924
|
+
ap.error("--download-handout needs --files-out")
|
|
925
|
+
# Not every endpoint is a file. An assignment can link a
|
|
926
|
+
# course page instead (the exam entries all point at
|
|
927
|
+
# the syllabus page) — there is nothing to download
|
|
928
|
+
# there, and the metadata has no `url` to follow.
|
|
929
|
+
if is_page:
|
|
930
|
+
print(" (a page, not a file — use --show-page)",
|
|
931
|
+
file=sys.stderr)
|
|
932
|
+
continue
|
|
933
|
+
# The endpoint is JSON describing the file; the name and
|
|
934
|
+
# the signed url are inside it. Fetch the metadata
|
|
935
|
+
# first so the file lands under its real display name.
|
|
936
|
+
try:
|
|
937
|
+
meta = s.get(endpoint, timeout=30)
|
|
938
|
+
meta.raise_for_status()
|
|
939
|
+
info = parse_json_loose(meta.text)
|
|
940
|
+
except Exception as e:
|
|
941
|
+
print(f" FAIL metadata {type(e).__name__}: {e}",
|
|
942
|
+
file=sys.stderr)
|
|
943
|
+
continue
|
|
944
|
+
signed = info.get("url")
|
|
945
|
+
if not signed:
|
|
946
|
+
print(" (no download url in the metadata)",
|
|
947
|
+
file=sys.stderr)
|
|
948
|
+
continue
|
|
949
|
+
raw_name = str(info.get("display_name")
|
|
950
|
+
or info.get("filename")
|
|
951
|
+
or h.get("assignment_id"))
|
|
952
|
+
safe = os.path.basename(raw_name).replace("/", "_")
|
|
953
|
+
safe = re.sub(r"[^\w.\-㐀-鿿]+", "_", safe).strip("_") \
|
|
954
|
+
or str(h.get("assignment_id"))
|
|
955
|
+
dest = os.path.join(args.files_out, str(cid), safe)
|
|
956
|
+
res = fetch_handout_bytes(s, endpoint, dest)
|
|
957
|
+
if "path" in res:
|
|
958
|
+
print(f" OK {res['bytes']:>9} B {safe}", file=sys.stderr)
|
|
959
|
+
else:
|
|
960
|
+
print(f" FAIL {res.get('error', res.get('status'))}",
|
|
961
|
+
file=sys.stderr)
|
|
962
|
+
return 0
|
|
963
|
+
|
|
964
|
+
# ---- files mode short-circuits the announcement pipeline ----
|
|
965
|
+
if args.list_files or args.download_files:
|
|
966
|
+
picked = courses if args.all_courses else [
|
|
967
|
+
c for c in courses
|
|
968
|
+
if any(find_course([c], n) for n in args.course)]
|
|
969
|
+
if not picked:
|
|
970
|
+
ap.error("no course matched --course")
|
|
971
|
+
total = ok = skipped = 0
|
|
972
|
+
for c in picked:
|
|
973
|
+
name = c.get("name") or str(c.get("id"))
|
|
974
|
+
try:
|
|
975
|
+
files = fetch_course_files(s, c["id"])
|
|
976
|
+
except Exception as e:
|
|
977
|
+
print(f"{str(name)[:30]:32} FAIL {type(e).__name__}: {e}",
|
|
978
|
+
file=sys.stderr)
|
|
979
|
+
continue
|
|
980
|
+
# Announcement attachments are a SEPARATE set from the Files tree
|
|
981
|
+
# — one term had 5 of these and 64 in the tree. A run that only
|
|
982
|
+
# walks the tree misses them entirely.
|
|
983
|
+
try:
|
|
984
|
+
topics = fetch_announcements(s, c["id"])
|
|
985
|
+
files = files + fetch_announcement_attachments(s, topics)
|
|
986
|
+
except Exception as e:
|
|
987
|
+
print(f" (announcement attachments skipped: "
|
|
988
|
+
f"{type(e).__name__}: {e})", file=sys.stderr)
|
|
989
|
+
mb = sum(f["size"] or 0 for f in files) / 1048576
|
|
990
|
+
print(f"\n{str(name)[:40]} — {len(files)} 檔 {mb:.1f} MB", file=sys.stderr)
|
|
991
|
+
for f in files:
|
|
992
|
+
total += 1
|
|
993
|
+
size_mb = (f["size"] or 0) / 1048576
|
|
994
|
+
if args.list_files:
|
|
995
|
+
print(f" [{f['id']}] {f['folder'][:30]:32} "
|
|
996
|
+
f"{str(f['name'])[:46]:48} {size_mb:7.2f} MB"
|
|
997
|
+
+ (" LOCKED" if f["locked"] else ""), file=sys.stderr)
|
|
998
|
+
if args.download_files:
|
|
999
|
+
if not args.files_out:
|
|
1000
|
+
ap.error("--download-files needs --files-out")
|
|
1001
|
+
if args.max_mb and size_mb > args.max_mb:
|
|
1002
|
+
skipped += 1
|
|
1003
|
+
continue
|
|
1004
|
+
# display_name is the human filename; `filename` is the
|
|
1005
|
+
# storage name and looks like "1789962952_790__lecture_ch1.pdf".
|
|
1006
|
+
raw_name = str(f.get("name") or f.get("filename") or f.get("id"))
|
|
1007
|
+
safe = os.path.basename(raw_name).replace("/", "_")
|
|
1008
|
+
safe = re.sub(r"[^\w.\-㐀-鿿]+", "_", safe).strip("_") or str(f["id"])
|
|
1009
|
+
# No name-collision disambiguation is needed: dest embeds
|
|
1010
|
+
# the file's own id, so two files with the same display
|
|
1011
|
+
# name land in different directories. Renaming here would
|
|
1012
|
+
# invent a suffix the source never had.
|
|
1013
|
+
dest = os.path.join(args.files_out, str(c["id"]), str(f["id"]), safe)
|
|
1014
|
+
magic = b"%PDF-" if "pdf" in (f["content_type"] or "") else None
|
|
1015
|
+
res = download_file(s, f["url"], dest, magic)
|
|
1016
|
+
if "path" in res:
|
|
1017
|
+
ok += 1
|
|
1018
|
+
print(f" OK {res['bytes']:>9} B {safe[:52]}", file=sys.stderr)
|
|
1019
|
+
else:
|
|
1020
|
+
print(f" FAIL {f['id']} {res.get('error', res['status'])}",
|
|
1021
|
+
file=sys.stderr)
|
|
1022
|
+
print(f"\n{total} listed / {ok} downloaded / {skipped} skipped",
|
|
1023
|
+
file=sys.stderr)
|
|
1024
|
+
return 0
|
|
1025
|
+
print(f"enrolled in {len(courses)} course(s)", file=sys.stderr)
|
|
1026
|
+
|
|
1027
|
+
if args.all_courses:
|
|
1028
|
+
targets = courses
|
|
1029
|
+
else:
|
|
1030
|
+
targets = []
|
|
1031
|
+
for needle in args.course:
|
|
1032
|
+
hit = find_course(courses, needle)
|
|
1033
|
+
if hit is None:
|
|
1034
|
+
print(f" ! no course matches {needle!r}", file=sys.stderr)
|
|
1035
|
+
continue
|
|
1036
|
+
targets.append(hit)
|
|
1037
|
+
if not targets:
|
|
1038
|
+
print("\navailable courses:", file=sys.stderr)
|
|
1039
|
+
for c in courses:
|
|
1040
|
+
print(f" {c.get('id')}\t{c.get('name')}", file=sys.stderr)
|
|
1041
|
+
return 1
|
|
1042
|
+
|
|
1043
|
+
annos: list[tuple[str, dict]] = []
|
|
1044
|
+
for c in targets:
|
|
1045
|
+
name = c.get("name", str(c.get("id")))
|
|
1046
|
+
# One course failing must not abort the batch. A 403 here means the
|
|
1047
|
+
# student is enrolled but not a TA of that course's announcements —
|
|
1048
|
+
# a normal state, not an error to raise on. Log it and continue.
|
|
1049
|
+
try:
|
|
1050
|
+
items = fetch_announcements(s, c.get("id"))
|
|
1051
|
+
except requests.HTTPError as e:
|
|
1052
|
+
code = e.response.status_code if e.response is not None else "?"
|
|
1053
|
+
print(f" {name}: skipped (HTTP {code})", file=sys.stderr)
|
|
1054
|
+
continue
|
|
1055
|
+
except requests.RequestException as e:
|
|
1056
|
+
print(f" {name}: skipped ({type(e).__name__})", file=sys.stderr)
|
|
1057
|
+
continue
|
|
1058
|
+
kept = 0
|
|
1059
|
+
for a in items:
|
|
1060
|
+
ts = a.get("posted_at")
|
|
1061
|
+
if since and ts:
|
|
1062
|
+
try:
|
|
1063
|
+
d = datetime.date.fromisoformat(ts[:10])
|
|
1064
|
+
if d < since:
|
|
1065
|
+
continue
|
|
1066
|
+
except ValueError:
|
|
1067
|
+
pass
|
|
1068
|
+
annos.append((name, a))
|
|
1069
|
+
kept += 1
|
|
1070
|
+
print(f" {name}: {kept} announcement(s) in range", file=sys.stderr)
|
|
1071
|
+
|
|
1072
|
+
doc = render(since, annos)
|
|
1073
|
+
if args.stdout:
|
|
1074
|
+
print(doc)
|
|
1075
|
+
return 0
|
|
1076
|
+
|
|
1077
|
+
outdir = args.out or os.getcwd()
|
|
1078
|
+
os.makedirs(outdir, exist_ok=True)
|
|
1079
|
+
path = os.path.join(outdir, f"{datetime.date.today()}-ntu-cool-announcements.md")
|
|
1080
|
+
# write then rename: an interrupted run leaves no half-file that later looks
|
|
1081
|
+
# like a complete capture of the day
|
|
1082
|
+
tmp = path + ".part"
|
|
1083
|
+
with open(tmp, "w", encoding="utf-8") as f:
|
|
1084
|
+
f.write(doc)
|
|
1085
|
+
os.replace(tmp, path)
|
|
1086
|
+
print(f"wrote {path} ({len(annos)} announcements, {len(doc)} chars)", file=sys.stderr)
|
|
1087
|
+
return 0
|
|
1088
|
+
|
|
1089
|
+
|
|
1090
|
+
if __name__ == "__main__":
|
|
1091
|
+
sys.exit(main())
|