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_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())