ntu-cool-cli 1.3.2__tar.gz → 1.3.4__tar.gz
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.2/scripts/ntu_cool_cli.egg-info → ntu_cool_cli-1.3.4}/PKG-INFO +2 -1
- {ntu_cool_cli-1.3.2 → ntu_cool_cli-1.3.4}/pyproject.toml +8 -1
- {ntu_cool_cli-1.3.2 → ntu_cool_cli-1.3.4/scripts/ntu_cool_cli.egg-info}/PKG-INFO +2 -1
- {ntu_cool_cli-1.3.2 → ntu_cool_cli-1.3.4}/scripts/ntu_fetch.py +3 -3
- {ntu_cool_cli-1.3.2 → ntu_cool_cli-1.3.4}/tests/test_ntu_fetch.py +183 -1
- {ntu_cool_cli-1.3.2 → ntu_cool_cli-1.3.4}/LICENSE +0 -0
- {ntu_cool_cli-1.3.2 → ntu_cool_cli-1.3.4}/README.md +0 -0
- {ntu_cool_cli-1.3.2 → ntu_cool_cli-1.3.4}/scripts/ntu_cool_cli.egg-info/SOURCES.txt +0 -0
- {ntu_cool_cli-1.3.2 → ntu_cool_cli-1.3.4}/scripts/ntu_cool_cli.egg-info/dependency_links.txt +0 -0
- {ntu_cool_cli-1.3.2 → ntu_cool_cli-1.3.4}/scripts/ntu_cool_cli.egg-info/entry_points.txt +0 -0
- {ntu_cool_cli-1.3.2 → ntu_cool_cli-1.3.4}/scripts/ntu_cool_cli.egg-info/requires.txt +0 -0
- {ntu_cool_cli-1.3.2 → ntu_cool_cli-1.3.4}/scripts/ntu_cool_cli.egg-info/top_level.txt +0 -0
- {ntu_cool_cli-1.3.2 → ntu_cool_cli-1.3.4}/setup.cfg +0 -0
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ntu-cool-cli
|
|
3
|
-
Version: 1.3.
|
|
3
|
+
Version: 1.3.4
|
|
4
4
|
Summary: Read-only CLI for NTU Cool: course announcements, course files, handouts, and submission status.
|
|
5
5
|
Author-email: Gary Xie <gary.1020.xie@gmail.com>
|
|
6
|
+
Maintainer-email: Gary Xie <gary.1020.xie@gmail.com>
|
|
6
7
|
License-Expression: MIT
|
|
7
8
|
Project-URL: Homepage, https://github.com/Gary1020-byte/ntu-cool-cli
|
|
8
9
|
Project-URL: Repository, https://github.com/Gary1020-byte/ntu-cool-cli
|
|
@@ -4,15 +4,22 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "ntu-cool-cli"
|
|
7
|
-
version = "1.3.
|
|
7
|
+
version = "1.3.4"
|
|
8
8
|
description = "Read-only CLI for NTU Cool: course announcements, course files, handouts, and submission status."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.9"
|
|
11
11
|
license = "MIT"
|
|
12
12
|
license-files = ["LICENSE"]
|
|
13
|
+
# Both keys, not just `authors`. Older setuptools splits the author table
|
|
14
|
+
# into author/author_email and leaves the PyPI "author" field blank, so the
|
|
15
|
+
# index page showed an email with no name next to it. `maintainer` is the
|
|
16
|
+
# field the index renders for the person responsible for the release.
|
|
13
17
|
authors = [
|
|
14
18
|
{ name = "Gary Xie", email = "gary.1020.xie@gmail.com" },
|
|
15
19
|
]
|
|
20
|
+
maintainers = [
|
|
21
|
+
{ name = "Gary Xie", email = "gary.1020.xie@gmail.com" },
|
|
22
|
+
]
|
|
16
23
|
keywords = [
|
|
17
24
|
"ntu", "cool", "ceiba", "cli", "announcements", "course", "files",
|
|
18
25
|
"homework", "handout", "student", "scraper", "read-only",
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ntu-cool-cli
|
|
3
|
-
Version: 1.3.
|
|
3
|
+
Version: 1.3.4
|
|
4
4
|
Summary: Read-only CLI for NTU Cool: course announcements, course files, handouts, and submission status.
|
|
5
5
|
Author-email: Gary Xie <gary.1020.xie@gmail.com>
|
|
6
|
+
Maintainer-email: Gary Xie <gary.1020.xie@gmail.com>
|
|
6
7
|
License-Expression: MIT
|
|
7
8
|
Project-URL: Homepage, https://github.com/Gary1020-byte/ntu-cool-cli
|
|
8
9
|
Project-URL: Repository, https://github.com/Gary1020-byte/ntu-cool-cli
|
|
@@ -239,8 +239,8 @@ def fetch_all_courses_historical(s: requests.Session) -> list[dict]:
|
|
|
239
239
|
|
|
240
240
|
This endpoint is a trap. Measured 2026-10-23 on a real account: it answered
|
|
241
241
|
with 10 courses while the current roster had 7. The extras were last term's
|
|
242
|
-
(
|
|
243
|
-
|
|
242
|
+
(a term-bracket course title, a placement self-test, and two engineering
|
|
243
|
+
courses) plus three entries with `name: null` and
|
|
244
244
|
`access_restricted_by_date: true`. **Every one of them carried
|
|
245
245
|
`term: null`**, so there is no term field to filter on — you cannot tell
|
|
246
246
|
a stale course from a current one after the fact.
|
|
@@ -1002,7 +1002,7 @@ def main() -> int:
|
|
|
1002
1002
|
skipped += 1
|
|
1003
1003
|
continue
|
|
1004
1004
|
# display_name is the human filename; `filename` is the
|
|
1005
|
-
# storage name and looks like "
|
|
1005
|
+
# storage name and looks like "<TIMESTAMP>_<ID>__<ORIGINAL-NAME>".
|
|
1006
1006
|
raw_name = str(f.get("name") or f.get("filename") or f.get("id"))
|
|
1007
1007
|
safe = os.path.basename(raw_name).replace("/", "_")
|
|
1008
1008
|
safe = re.sub(r"[^\w.\-㐀-鿿]+", "_", safe).strip("_") or str(f["id"])
|
|
@@ -396,6 +396,103 @@ class TestNoPersonalCourseRoster(unittest.TestCase):
|
|
|
396
396
|
ID_RE = re.compile(r"(?<![0-9])[56][0-9]{4}(?![0-9])")
|
|
397
397
|
CODE_RE = re.compile(r"ESOE[0-9]{4}")
|
|
398
398
|
|
|
399
|
+
# --- the rules that were missing when the guard was first written ---
|
|
400
|
+
#
|
|
401
|
+
# The original pair above catches a course id and an ESOE code. It does
|
|
402
|
+
# NOT catch the four things that actually leaked:
|
|
403
|
+
#
|
|
404
|
+
# 1. the six-digit course code as printed on the handout header, which
|
|
405
|
+
# is written with a space in the middle and so misses ID_RE
|
|
406
|
+
# 2. the student id, `B` + eight digits -- a different shape entirely
|
|
407
|
+
# 3. a literal password pasted into an `authenticate(...)` call
|
|
408
|
+
# 4. the course TITLES and the room numbers, which are the identifying
|
|
409
|
+
# part: a roster of four real course names with their exam dates
|
|
410
|
+
# says who this account belongs to as clearly as a course id does
|
|
411
|
+
# 5. the real FILE NAMES -- a course's own worksheet, solution key and
|
|
412
|
+
# optional-practice PDFs. These are the actual coursework files, so
|
|
413
|
+
# a filename is more identifying than a course title: it names the
|
|
414
|
+
# assignment the student sat. A filename rule is what was missing
|
|
415
|
+
# when a reviewer pointed at three real homework filenames still
|
|
416
|
+
# sitting in the published skill.
|
|
417
|
+
#
|
|
418
|
+
# Rules 1-3 and 5 are hard identifiers and need no context. Rule 4 needs
|
|
419
|
+
# context, because the same words appear in ordinary prose ("the Files
|
|
420
|
+
# tree structure", "a maths course"), so a title only counts when it
|
|
421
|
+
# sits near a course-context marker.
|
|
422
|
+
#
|
|
423
|
+
# Nothing here lists a real course name, student id, password or
|
|
424
|
+
# filename: a denylist of the leaked values would itself be the leak.
|
|
425
|
+
# Every rule is a shape, so it also catches values added after this
|
|
426
|
+
# file was written.
|
|
427
|
+
STUDENT_ID_RE = re.compile(r"(?<![A-Za-z0-9])[BR]\d{8}(?![0-9])")
|
|
428
|
+
COURSE_CODE_RE = re.compile(r"(?<!\d)[56]\d{2}\s?\d{5}(?!\d)")
|
|
429
|
+
PASSWORD_RE = re.compile(r"authenticate\(\s*[\"'][^\"']+[\"']\s*\)")
|
|
430
|
+
# A course title: a Chinese discipline noun, optionally with a leading
|
|
431
|
+
# qualifier and an optional trailing numeral (力學一, 電路一, 數學).
|
|
432
|
+
CN_TITLE_RE = re.compile(
|
|
433
|
+
r"(?<![一-鿿])[一-鿿]{2,4}(?:力學|數學|電路|結構|物理|化學|電子)"
|
|
434
|
+
r"(?:[一二三四五六七八九])?(?![一-鿿])")
|
|
435
|
+
EN_TITLE_RE = re.compile(
|
|
436
|
+
r"\b[A-Z][a-z]+ (?:Mechanics|Circuit Design"
|
|
437
|
+
r"|Electronics|Structures)\b"
|
|
438
|
+
r"|\b[A-Z][a-z]+ [A-Z][a-z]+ Structures\b")
|
|
439
|
+
ROOM_RE = re.compile(r"(?<![0-9])\d{2,3}\s?(?:教室|實驗室)")
|
|
440
|
+
|
|
441
|
+
# --- filenames ---
|
|
442
|
+
#
|
|
443
|
+
# A filename is the most identifying string in the whole tree: it names
|
|
444
|
+
# the exact worksheet, solution key or midterm paper the student sat.
|
|
445
|
+
# Redacting the course title while leaving its homework filenames in
|
|
446
|
+
# place leaves the account just as findable.
|
|
447
|
+
#
|
|
448
|
+
# So a filename counts when it carries a piece that identifies:
|
|
449
|
+
# - a Chinese discipline noun (力學 / 數學 / 電路 / 結構 / 物理 / 化學)
|
|
450
|
+
# - a course prefix (a two-to-four letter course tag, then CA / HW /
|
|
451
|
+
# CH / note / mid / exam)
|
|
452
|
+
# - an academic-term prefix (a three-or-four-digit term, then _ or -)
|
|
453
|
+
# - a student id
|
|
454
|
+
# - an English course name
|
|
455
|
+
#
|
|
456
|
+
# A generic filename does not count: a Chinese name that names no
|
|
457
|
+
# discipline, or a plain English noun, say nothing about whose they are.
|
|
458
|
+
# The rule must not be so broad that it rejects every .pdf in the tree --
|
|
459
|
+
# a guard that cries wolf is a guard that gets disabled.
|
|
460
|
+
FILENAME_RE = re.compile(
|
|
461
|
+
r"[^\s`|()<>\[\]{}\"'`,;。,、::/\\]+?"
|
|
462
|
+
r"\.(?:pdf|zip|pptx?|docx?|xlsx?)")
|
|
463
|
+
FILENAME_CJK_RE = re.compile(r"力學|數學|電路|結構|物理|化學|電子|工力")
|
|
464
|
+
FILENAME_PREFIX_RE = re.compile(
|
|
465
|
+
r"^[A-Za-z]{2,4}_(?:CA|HW|CH|note|mid|exam)", re.IGNORECASE)
|
|
466
|
+
FILENAME_TERM_RE = re.compile(r"^\d{3,4}[-_]")
|
|
467
|
+
FILENAME_EN_RE = re.compile(
|
|
468
|
+
r"Mechanics|Circuits|Circuit_Design|Data_Structures|Electronics",
|
|
469
|
+
re.IGNORECASE)
|
|
470
|
+
|
|
471
|
+
def _filename_reason(self, token):
|
|
472
|
+
"""Why this filename identifies someone, or None if it is generic."""
|
|
473
|
+
base = token.rsplit(".", 1)[0]
|
|
474
|
+
if "*" in base:
|
|
475
|
+
base = base.split("*")[0]
|
|
476
|
+
if not base:
|
|
477
|
+
return None
|
|
478
|
+
if self.FILENAME_CJK_RE.search(base):
|
|
479
|
+
return "a discipline noun"
|
|
480
|
+
if self.FILENAME_PREFIX_RE.match(base):
|
|
481
|
+
return "a course prefix"
|
|
482
|
+
if self.FILENAME_TERM_RE.match(base):
|
|
483
|
+
return "an academic-term prefix"
|
|
484
|
+
if self.STUDENT_ID_RE.search(base):
|
|
485
|
+
return "a student id"
|
|
486
|
+
if self.FILENAME_EN_RE.search(base):
|
|
487
|
+
return "an English course name"
|
|
488
|
+
return None
|
|
489
|
+
# Words that mean "this line is about a course", not "this line is prose".
|
|
490
|
+
CONTEXT_RE = re.compile(
|
|
491
|
+
r"期中考|期末考|模擬考|小考|課程|課堂|助教|講義|作業|學期|科目"
|
|
492
|
+
r"|\bcourses?\b|\bcourse\b|\broster\b|\bterm\b|\bhomework\b"
|
|
493
|
+
r"|\bHW\d|\bCA\d|ESOE|cool\.ntu|/courses/")
|
|
494
|
+
CONTEXT_LINES = 3
|
|
495
|
+
|
|
399
496
|
def _repo_files(self):
|
|
400
497
|
"""Files that would actually be published: the git-tracked set.
|
|
401
498
|
|
|
@@ -427,6 +524,91 @@ class TestNoPersonalCourseRoster(unittest.TestCase):
|
|
|
427
524
|
self.assertIsNone(
|
|
428
525
|
hit, f"ESOE course code {hit.group(0) if hit else ''!r} in {path}")
|
|
429
526
|
|
|
527
|
+
def test_no_student_ids_anywhere(self):
|
|
528
|
+
"""`B` + eight digits is a student id, and it is not a course id."""
|
|
529
|
+
for path in self._repo_files():
|
|
530
|
+
src = path.read_text(encoding="utf-8", errors="replace")
|
|
531
|
+
hit = self.STUDENT_ID_RE.search(src)
|
|
532
|
+
self.assertIsNone(
|
|
533
|
+
hit, f"student id {hit.group(0) if hit else ''!r} in {path}")
|
|
534
|
+
|
|
535
|
+
def test_no_six_digit_course_codes_anywhere(self):
|
|
536
|
+
"""The handout header carries the course code as six digits with a
|
|
537
|
+
space in the middle, so the five-digit course-id rule never sees it."""
|
|
538
|
+
for path in self._repo_files():
|
|
539
|
+
src = path.read_text(encoding="utf-8", errors="replace")
|
|
540
|
+
hit = self.COURSE_CODE_RE.search(src)
|
|
541
|
+
self.assertIsNone(
|
|
542
|
+
hit, f"course code {hit.group(0) if hit else ''!r} in {path}")
|
|
543
|
+
|
|
544
|
+
def test_no_literal_passwords_anywhere(self):
|
|
545
|
+
"""A password pasted into an `authenticate(...)` call is a leak, even
|
|
546
|
+
when the password is one the course published."""
|
|
547
|
+
for path in self._repo_files():
|
|
548
|
+
src = path.read_text(encoding="utf-8", errors="replace")
|
|
549
|
+
hit = self.PASSWORD_RE.search(src)
|
|
550
|
+
self.assertIsNone(
|
|
551
|
+
hit, f"literal password {hit.group(0) if hit else ''!r} in {path}")
|
|
552
|
+
|
|
553
|
+
def test_no_course_titles_in_course_context(self):
|
|
554
|
+
"""A course title plus an exam date says whose account this is.
|
|
555
|
+
|
|
556
|
+
The same words appear in ordinary prose, so a title only counts when
|
|
557
|
+
a course-context marker sits within a few lines of it. Without the
|
|
558
|
+
context window this rule fires on 'the Files tree structure'."""
|
|
559
|
+
for path in self._repo_files():
|
|
560
|
+
lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
|
|
561
|
+
for i, line in enumerate(lines):
|
|
562
|
+
for rx, label in ((self.CN_TITLE_RE, "course title"),
|
|
563
|
+
(self.EN_TITLE_RE, "course title")):
|
|
564
|
+
hit = rx.search(line)
|
|
565
|
+
if not hit:
|
|
566
|
+
continue
|
|
567
|
+
lo = max(0, i - self.CONTEXT_LINES)
|
|
568
|
+
hi = min(len(lines), i + self.CONTEXT_LINES + 1)
|
|
569
|
+
window = "\n".join(lines[lo:hi])
|
|
570
|
+
if not self.CONTEXT_RE.search(window):
|
|
571
|
+
continue
|
|
572
|
+
self.fail(
|
|
573
|
+
f"{label} {hit.group(0)!r} in {path}:{i + 1} "
|
|
574
|
+
f"near a course-context marker:\n{line.strip()[:160]}")
|
|
575
|
+
|
|
576
|
+
def test_no_identifying_filenames(self):
|
|
577
|
+
"""A filename that names a course's actual coursework identifies the
|
|
578
|
+
student more directly than a course title does.
|
|
579
|
+
|
|
580
|
+
Generic filenames are allowed: 期中座位表.pdf, worksheet.pdf and
|
|
581
|
+
seating.pdf do not say whose they are."""
|
|
582
|
+
for path in self._repo_files():
|
|
583
|
+
src = path.read_text(encoding="utf-8", errors="replace")
|
|
584
|
+
for i, line in enumerate(src.splitlines(), 1):
|
|
585
|
+
for match in self.FILENAME_RE.finditer(line):
|
|
586
|
+
token = match.group(0)
|
|
587
|
+
reason = self._filename_reason(token)
|
|
588
|
+
if reason is None:
|
|
589
|
+
continue
|
|
590
|
+
self.fail(
|
|
591
|
+
f"filename {token!r} in {path}:{i} carries {reason}:\n"
|
|
592
|
+
f"{line.strip()[:160]}")
|
|
593
|
+
|
|
594
|
+
def test_no_room_numbers_in_course_context(self):
|
|
595
|
+
"""A room number is only identifying in a course context; the same
|
|
596
|
+
digits appear in a port number or a code elsewhere."""
|
|
597
|
+
for path in self._repo_files():
|
|
598
|
+
lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
|
|
599
|
+
for i, line in enumerate(lines):
|
|
600
|
+
hit = self.ROOM_RE.search(line)
|
|
601
|
+
if not hit:
|
|
602
|
+
continue
|
|
603
|
+
lo = max(0, i - self.CONTEXT_LINES)
|
|
604
|
+
hi = min(len(lines), i + self.CONTEXT_LINES + 1)
|
|
605
|
+
window = "\n".join(lines[lo:hi])
|
|
606
|
+
if not self.CONTEXT_RE.search(window):
|
|
607
|
+
continue
|
|
608
|
+
self.fail(
|
|
609
|
+
f"room {hit.group(0)!r} in {path}:{i + 1} "
|
|
610
|
+
f"near a course-context marker:\n{line.strip()[:160]}")
|
|
611
|
+
|
|
430
612
|
|
|
431
613
|
class _PagedResponse(_FakeResponse):
|
|
432
614
|
def __init__(self, payload, status=200, next_url=None):
|
|
@@ -667,7 +849,7 @@ class TestFetchAssignmentHandouts(unittest.TestCase):
|
|
|
667
849
|
'<p>see <a class="instructure_file_link" '
|
|
668
850
|
'href="https://cool.ntu.edu.tw/courses/9/files/10140367/download" '
|
|
669
851
|
'data-api-endpoint="https://cool.ntu.edu.tw/api/v1/courses/9/files/10140367" '
|
|
670
|
-
'data-api-returntype="File">
|
|
852
|
+
'data-api-returntype="File"><PREFIX>_CA3.zip</a></p>'
|
|
671
853
|
)
|
|
672
854
|
|
|
673
855
|
def test_finds_the_endpoint_in_the_description(self):
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ntu_cool_cli-1.3.2 → ntu_cool_cli-1.3.4}/scripts/ntu_cool_cli.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|