get-class-material 0.2.7__tar.gz → 0.2.8__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.
- {get_class_material-0.2.7 → get_class_material-0.2.8}/PKG-INFO +24 -6
- {get_class_material-0.2.7 → get_class_material-0.2.8}/README.md +23 -5
- {get_class_material-0.2.7 → get_class_material-0.2.8}/get_class_material.egg-info/PKG-INFO +24 -6
- {get_class_material-0.2.7 → get_class_material-0.2.8}/ntu_cool_materials/course_pipeline.py +147 -35
- {get_class_material-0.2.7 → get_class_material-0.2.8}/pyproject.toml +1 -1
- get_class_material-0.2.8/tests/test_course_pipeline.py +484 -0
- get_class_material-0.2.7/tests/test_course_pipeline.py +0 -146
- {get_class_material-0.2.7 → get_class_material-0.2.8}/LICENSE +0 -0
- {get_class_material-0.2.7 → get_class_material-0.2.8}/get_class_material.egg-info/SOURCES.txt +0 -0
- {get_class_material-0.2.7 → get_class_material-0.2.8}/get_class_material.egg-info/dependency_links.txt +0 -0
- {get_class_material-0.2.7 → get_class_material-0.2.8}/get_class_material.egg-info/entry_points.txt +0 -0
- {get_class_material-0.2.7 → get_class_material-0.2.8}/get_class_material.egg-info/requires.txt +0 -0
- {get_class_material-0.2.7 → get_class_material-0.2.8}/get_class_material.egg-info/top_level.txt +0 -0
- {get_class_material-0.2.7 → get_class_material-0.2.8}/ntu_cool_materials/__init__.py +0 -0
- {get_class_material-0.2.7 → get_class_material-0.2.8}/ntu_cool_materials/__main__.py +0 -0
- {get_class_material-0.2.7 → get_class_material-0.2.8}/ntu_cool_materials/announcements.py +0 -0
- {get_class_material-0.2.7 → get_class_material-0.2.8}/ntu_cool_materials/browser_session.py +0 -0
- {get_class_material-0.2.7 → get_class_material-0.2.8}/ntu_cool_materials/canvas_client.py +0 -0
- {get_class_material-0.2.7 → get_class_material-0.2.8}/ntu_cool_materials/cli.py +0 -0
- {get_class_material-0.2.7 → get_class_material-0.2.8}/ntu_cool_materials/cool_video.py +0 -0
- {get_class_material-0.2.7 → get_class_material-0.2.8}/ntu_cool_materials/doctor.py +0 -0
- {get_class_material-0.2.7 → get_class_material-0.2.8}/ntu_cool_materials/i18n.py +0 -0
- {get_class_material-0.2.7 → get_class_material-0.2.8}/ntu_cool_materials/media_naming.py +0 -0
- {get_class_material-0.2.7 → get_class_material-0.2.8}/ntu_cool_materials/pages.py +0 -0
- {get_class_material-0.2.7 → get_class_material-0.2.8}/ntu_cool_materials/session_client.py +0 -0
- {get_class_material-0.2.7 → get_class_material-0.2.8}/ntu_cool_materials/storage.py +0 -0
- {get_class_material-0.2.7 → get_class_material-0.2.8}/ntu_cool_materials/sync.py +0 -0
- {get_class_material-0.2.7 → get_class_material-0.2.8}/ntu_cool_materials/text_extract.py +0 -0
- {get_class_material-0.2.7 → get_class_material-0.2.8}/ntu_cool_materials/youtube_cookies.py +0 -0
- {get_class_material-0.2.7 → get_class_material-0.2.8}/setup.cfg +0 -0
- {get_class_material-0.2.7 → get_class_material-0.2.8}/tests/test_announcements.py +0 -0
- {get_class_material-0.2.7 → get_class_material-0.2.8}/tests/test_browser_session.py +0 -0
- {get_class_material-0.2.7 → get_class_material-0.2.8}/tests/test_canvas_client.py +0 -0
- {get_class_material-0.2.7 → get_class_material-0.2.8}/tests/test_cli.py +0 -0
- {get_class_material-0.2.7 → get_class_material-0.2.8}/tests/test_cool_video.py +0 -0
- {get_class_material-0.2.7 → get_class_material-0.2.8}/tests/test_doctor.py +0 -0
- {get_class_material-0.2.7 → get_class_material-0.2.8}/tests/test_media_naming.py +0 -0
- {get_class_material-0.2.7 → get_class_material-0.2.8}/tests/test_session_client.py +0 -0
- {get_class_material-0.2.7 → get_class_material-0.2.8}/tests/test_storage.py +0 -0
- {get_class_material-0.2.7 → get_class_material-0.2.8}/tests/test_youtube_cookies.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: get-class-material
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.8
|
|
4
4
|
Summary: One-command bulk downloader for NTU COOL (Canvas) course materials — PDFs, lecture videos, and Pages.
|
|
5
5
|
Author-email: jabir <jabir95tsai@gmail.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -188,7 +188,7 @@ ntu-cool-gcm
|
|
|
188
188
|
上課影片: 新增 4、跳過 0、失敗 0
|
|
189
189
|
|
|
190
190
|
檔案存放位置:
|
|
191
|
-
C:\Users\你\ntu-cool-gcm_material\音樂、演化與大腦 Music, Evolution and the Brain (57544)
|
|
191
|
+
C:\Users\你\Documents\ntu-cool-gcm_material\音樂、演化與大腦 Music, Evolution and the Brain (57544)
|
|
192
192
|
```
|
|
193
193
|
|
|
194
194
|
**最後那行就是你的檔案放在哪。** 用檔案總管打開就能看到。
|
|
@@ -209,7 +209,7 @@ ntu-cool-gcm
|
|
|
209
209
|
下載到的目錄結構長這樣:
|
|
210
210
|
|
|
211
211
|
```
|
|
212
|
-
C:\Users\你\ntu-cool-gcm_material\
|
|
212
|
+
C:\Users\你\Documents\ntu-cool-gcm_material\
|
|
213
213
|
└── 音樂、演化與大腦 Music, Evolution and the Brain (57544)\
|
|
214
214
|
├── course_overview.md ← 這份課程的目錄索引(可以餵給 AI)
|
|
215
215
|
├── week1\
|
|
@@ -283,7 +283,19 @@ ntu-cool-gcm --skip-youtube --skip-cool-videos
|
|
|
283
283
|
ntu-cool-gcm --out D:\我的課程
|
|
284
284
|
```
|
|
285
285
|
|
|
286
|
-
|
|
286
|
+
預設是在你的「文件 / Documents」資料夾底下的 `ntu-cool-gcm_material/`。
|
|
287
|
+
|
|
288
|
+
如果你的目前資料夾已經有舊版建立的 `ntu-cool-gcm_material/`,工具會沿用那個資料夾,避免把舊檔案拆散。
|
|
289
|
+
|
|
290
|
+
### 下載 Word / PowerPoint / Excel / Zip 等非 PDF 檔
|
|
291
|
+
|
|
292
|
+
預設只下載 PDF,避免把 Word、PowerPoint、Zip 之類的檔案誤存成 `.pdf`。
|
|
293
|
+
|
|
294
|
+
如果老師有放這類檔案,請加:
|
|
295
|
+
|
|
296
|
+
```powershell
|
|
297
|
+
ntu-cool-gcm --all-file-types
|
|
298
|
+
```
|
|
287
299
|
|
|
288
300
|
### 已經知道課程 ID(網址裡的數字)
|
|
289
301
|
|
|
@@ -380,9 +392,15 @@ ntu-cool-gcm --refresh-session
|
|
|
380
392
|
|
|
381
393
|
### Q: 我的檔案到底放在哪
|
|
382
394
|
|
|
383
|
-
|
|
395
|
+
預設在你的「文件 / Documents」資料夾底下,叫 `ntu-cool-gcm_material/`。
|
|
396
|
+
|
|
397
|
+
常見位置像這樣:
|
|
398
|
+
|
|
399
|
+
```text
|
|
400
|
+
C:\Users\你\Documents\ntu-cool-gcm_material\
|
|
401
|
+
```
|
|
384
402
|
|
|
385
|
-
|
|
403
|
+
如果你以前已經在目前資料夾建立過 `ntu-cool-gcm_material/`,工具會優先沿用那個舊資料夾。
|
|
386
404
|
|
|
387
405
|
每次跑完工具最後一行也會印「檔案存放位置:」+ 完整路徑。
|
|
388
406
|
|
|
@@ -156,7 +156,7 @@ ntu-cool-gcm
|
|
|
156
156
|
上課影片: 新增 4、跳過 0、失敗 0
|
|
157
157
|
|
|
158
158
|
檔案存放位置:
|
|
159
|
-
C:\Users\你\ntu-cool-gcm_material\音樂、演化與大腦 Music, Evolution and the Brain (57544)
|
|
159
|
+
C:\Users\你\Documents\ntu-cool-gcm_material\音樂、演化與大腦 Music, Evolution and the Brain (57544)
|
|
160
160
|
```
|
|
161
161
|
|
|
162
162
|
**最後那行就是你的檔案放在哪。** 用檔案總管打開就能看到。
|
|
@@ -177,7 +177,7 @@ ntu-cool-gcm
|
|
|
177
177
|
下載到的目錄結構長這樣:
|
|
178
178
|
|
|
179
179
|
```
|
|
180
|
-
C:\Users\你\ntu-cool-gcm_material\
|
|
180
|
+
C:\Users\你\Documents\ntu-cool-gcm_material\
|
|
181
181
|
└── 音樂、演化與大腦 Music, Evolution and the Brain (57544)\
|
|
182
182
|
├── course_overview.md ← 這份課程的目錄索引(可以餵給 AI)
|
|
183
183
|
├── week1\
|
|
@@ -251,7 +251,19 @@ ntu-cool-gcm --skip-youtube --skip-cool-videos
|
|
|
251
251
|
ntu-cool-gcm --out D:\我的課程
|
|
252
252
|
```
|
|
253
253
|
|
|
254
|
-
|
|
254
|
+
預設是在你的「文件 / Documents」資料夾底下的 `ntu-cool-gcm_material/`。
|
|
255
|
+
|
|
256
|
+
如果你的目前資料夾已經有舊版建立的 `ntu-cool-gcm_material/`,工具會沿用那個資料夾,避免把舊檔案拆散。
|
|
257
|
+
|
|
258
|
+
### 下載 Word / PowerPoint / Excel / Zip 等非 PDF 檔
|
|
259
|
+
|
|
260
|
+
預設只下載 PDF,避免把 Word、PowerPoint、Zip 之類的檔案誤存成 `.pdf`。
|
|
261
|
+
|
|
262
|
+
如果老師有放這類檔案,請加:
|
|
263
|
+
|
|
264
|
+
```powershell
|
|
265
|
+
ntu-cool-gcm --all-file-types
|
|
266
|
+
```
|
|
255
267
|
|
|
256
268
|
### 已經知道課程 ID(網址裡的數字)
|
|
257
269
|
|
|
@@ -348,9 +360,15 @@ ntu-cool-gcm --refresh-session
|
|
|
348
360
|
|
|
349
361
|
### Q: 我的檔案到底放在哪
|
|
350
362
|
|
|
351
|
-
|
|
363
|
+
預設在你的「文件 / Documents」資料夾底下,叫 `ntu-cool-gcm_material/`。
|
|
364
|
+
|
|
365
|
+
常見位置像這樣:
|
|
366
|
+
|
|
367
|
+
```text
|
|
368
|
+
C:\Users\你\Documents\ntu-cool-gcm_material\
|
|
369
|
+
```
|
|
352
370
|
|
|
353
|
-
|
|
371
|
+
如果你以前已經在目前資料夾建立過 `ntu-cool-gcm_material/`,工具會優先沿用那個舊資料夾。
|
|
354
372
|
|
|
355
373
|
每次跑完工具最後一行也會印「檔案存放位置:」+ 完整路徑。
|
|
356
374
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: get-class-material
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.8
|
|
4
4
|
Summary: One-command bulk downloader for NTU COOL (Canvas) course materials — PDFs, lecture videos, and Pages.
|
|
5
5
|
Author-email: jabir <jabir95tsai@gmail.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -188,7 +188,7 @@ ntu-cool-gcm
|
|
|
188
188
|
上課影片: 新增 4、跳過 0、失敗 0
|
|
189
189
|
|
|
190
190
|
檔案存放位置:
|
|
191
|
-
C:\Users\你\ntu-cool-gcm_material\音樂、演化與大腦 Music, Evolution and the Brain (57544)
|
|
191
|
+
C:\Users\你\Documents\ntu-cool-gcm_material\音樂、演化與大腦 Music, Evolution and the Brain (57544)
|
|
192
192
|
```
|
|
193
193
|
|
|
194
194
|
**最後那行就是你的檔案放在哪。** 用檔案總管打開就能看到。
|
|
@@ -209,7 +209,7 @@ ntu-cool-gcm
|
|
|
209
209
|
下載到的目錄結構長這樣:
|
|
210
210
|
|
|
211
211
|
```
|
|
212
|
-
C:\Users\你\ntu-cool-gcm_material\
|
|
212
|
+
C:\Users\你\Documents\ntu-cool-gcm_material\
|
|
213
213
|
└── 音樂、演化與大腦 Music, Evolution and the Brain (57544)\
|
|
214
214
|
├── course_overview.md ← 這份課程的目錄索引(可以餵給 AI)
|
|
215
215
|
├── week1\
|
|
@@ -283,7 +283,19 @@ ntu-cool-gcm --skip-youtube --skip-cool-videos
|
|
|
283
283
|
ntu-cool-gcm --out D:\我的課程
|
|
284
284
|
```
|
|
285
285
|
|
|
286
|
-
|
|
286
|
+
預設是在你的「文件 / Documents」資料夾底下的 `ntu-cool-gcm_material/`。
|
|
287
|
+
|
|
288
|
+
如果你的目前資料夾已經有舊版建立的 `ntu-cool-gcm_material/`,工具會沿用那個資料夾,避免把舊檔案拆散。
|
|
289
|
+
|
|
290
|
+
### 下載 Word / PowerPoint / Excel / Zip 等非 PDF 檔
|
|
291
|
+
|
|
292
|
+
預設只下載 PDF,避免把 Word、PowerPoint、Zip 之類的檔案誤存成 `.pdf`。
|
|
293
|
+
|
|
294
|
+
如果老師有放這類檔案,請加:
|
|
295
|
+
|
|
296
|
+
```powershell
|
|
297
|
+
ntu-cool-gcm --all-file-types
|
|
298
|
+
```
|
|
287
299
|
|
|
288
300
|
### 已經知道課程 ID(網址裡的數字)
|
|
289
301
|
|
|
@@ -380,9 +392,15 @@ ntu-cool-gcm --refresh-session
|
|
|
380
392
|
|
|
381
393
|
### Q: 我的檔案到底放在哪
|
|
382
394
|
|
|
383
|
-
|
|
395
|
+
預設在你的「文件 / Documents」資料夾底下,叫 `ntu-cool-gcm_material/`。
|
|
396
|
+
|
|
397
|
+
常見位置像這樣:
|
|
398
|
+
|
|
399
|
+
```text
|
|
400
|
+
C:\Users\你\Documents\ntu-cool-gcm_material\
|
|
401
|
+
```
|
|
384
402
|
|
|
385
|
-
|
|
403
|
+
如果你以前已經在目前資料夾建立過 `ntu-cool-gcm_material/`,工具會優先沿用那個舊資料夾。
|
|
386
404
|
|
|
387
405
|
每次跑完工具最後一行也會印「檔案存放位置:」+ 完整路徑。
|
|
388
406
|
|
|
@@ -304,6 +304,41 @@ _KNOWN_FILE_EXTS = {
|
|
|
304
304
|
}
|
|
305
305
|
|
|
306
306
|
|
|
307
|
+
def _file_item_real_ext(item: dict[str, Any]) -> str:
|
|
308
|
+
"""Best-effort original extension for a Canvas File module item."""
|
|
309
|
+
title = str(item.get("title") or "").strip()
|
|
310
|
+
content_details = item.get("content_details") or {}
|
|
311
|
+
display_name = str(content_details.get("display_name") or "")
|
|
312
|
+
real_ext = Path(display_name).suffix.lower() if display_name else ""
|
|
313
|
+
if not real_ext:
|
|
314
|
+
real_ext = Path(title).suffix.lower()
|
|
315
|
+
return real_ext
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _file_item_target_name(item: dict[str, Any], *, all_file_types: bool) -> str:
|
|
319
|
+
"""Filename for a Canvas File item.
|
|
320
|
+
|
|
321
|
+
Default mode downloads every file but writes it to disk with a `.pdf`
|
|
322
|
+
extension regardless of the original type — most NTU course material IS
|
|
323
|
+
PDF, this keeps the layout uniform for downstream AI tools, and most
|
|
324
|
+
apps still open files whose extension is wrong (Word/Excel sniff the
|
|
325
|
+
binary signature). The trade-off: if a tool relies purely on extension
|
|
326
|
+
(e.g. zip extractors), the file won't open. Users who hit that pass
|
|
327
|
+
`--all-file-types` to keep real extensions like .pptx / .xlsx / .zip.
|
|
328
|
+
|
|
329
|
+
Always returns a name (never None). Default-mode behavior changed
|
|
330
|
+
from "skip non-PDF" → "force .pdf"; reverting required because the
|
|
331
|
+
skip-by-default surprised users who expected every file to land.
|
|
332
|
+
"""
|
|
333
|
+
title = str(item.get("title") or "").strip() or f"item-{item.get('id')}"
|
|
334
|
+
real_ext = _file_item_real_ext(item)
|
|
335
|
+
use_ext = real_ext if all_file_types and real_ext else ".pdf"
|
|
336
|
+
title_ext = Path(title).suffix.lower()
|
|
337
|
+
stem = title[:-len(title_ext)] if title_ext in _KNOWN_FILE_EXTS else title
|
|
338
|
+
safe_title = sanitize_teacher_title(stem)
|
|
339
|
+
return f"{safe_title}{use_ext}"
|
|
340
|
+
|
|
341
|
+
|
|
307
342
|
def download_files(
|
|
308
343
|
plan: CoursePlan, client: CanvasSessionClient, *, all_file_types: bool = False,
|
|
309
344
|
) -> StageStats:
|
|
@@ -329,31 +364,11 @@ def download_files(
|
|
|
329
364
|
continue
|
|
330
365
|
file_id = str(item.get("content_id") or "")
|
|
331
366
|
title = str(item.get("title") or "").strip() or f"item-{item.get('id')}"
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
real_ext = Path(display_name).suffix.lower() if display_name else ""
|
|
338
|
-
if not real_ext:
|
|
339
|
-
real_ext = Path(title).suffix.lower()
|
|
340
|
-
|
|
341
|
-
# Decide what extension to use ON DISK.
|
|
342
|
-
if all_file_types:
|
|
343
|
-
use_ext = real_ext or ".pdf"
|
|
344
|
-
else:
|
|
345
|
-
use_ext = ".pdf"
|
|
346
|
-
if real_ext and real_ext != ".pdf":
|
|
347
|
-
forced_pdf_count += 1
|
|
348
|
-
|
|
349
|
-
# Strip any known extension from the title (case-insensitive) so we
|
|
350
|
-
# don't double up: "syllabus.pdf" + ".pdf" = "syllabus.pdf", not
|
|
351
|
-
# "syllabus.pdf.pdf"; "MS-02_C03-Ex.xlsx" + ".pdf" = "MS-02_C03-Ex.pdf",
|
|
352
|
-
# not "MS-02_C03-Ex.xlsx.pdf".
|
|
353
|
-
title_ext = Path(title).suffix.lower()
|
|
354
|
-
stem = title[:-len(title_ext)] if title_ext in _KNOWN_FILE_EXTS else title
|
|
355
|
-
safe_title = sanitize_teacher_title(stem)
|
|
356
|
-
target = week.week_dir / f"{safe_title}{use_ext}"
|
|
367
|
+
real_ext = _file_item_real_ext(item)
|
|
368
|
+
target_name = _file_item_target_name(item, all_file_types=all_file_types)
|
|
369
|
+
target = week.week_dir / target_name
|
|
370
|
+
if not all_file_types and real_ext and real_ext != ".pdf":
|
|
371
|
+
forced_pdf_count += 1
|
|
357
372
|
if target.exists():
|
|
358
373
|
stats.skipped += 1
|
|
359
374
|
continue
|
|
@@ -451,6 +466,91 @@ def _delete_orphan_format_fragments(videos_dir: Path) -> int:
|
|
|
451
466
|
return n
|
|
452
467
|
|
|
453
468
|
|
|
469
|
+
def _count_youtube_urls_in_plan(plan: CoursePlan) -> int:
|
|
470
|
+
"""Distinct YouTube video IDs across the whole plan.
|
|
471
|
+
|
|
472
|
+
Used to size the "found N YouTube videos, want to log in?" prompt.
|
|
473
|
+
Distinct (not raw item count) because the same video may appear in
|
|
474
|
+
multiple modules and we don't want to overstate the work."""
|
|
475
|
+
seen: set[str] = set()
|
|
476
|
+
for week in plan.weeks:
|
|
477
|
+
for item in week.items:
|
|
478
|
+
if item.get("type") not in {"ExternalUrl", "ExternalTool"}:
|
|
479
|
+
continue
|
|
480
|
+
raw = str(item.get("external_url") or item.get("url") or "")
|
|
481
|
+
for vid in extract_youtube_ids(raw):
|
|
482
|
+
seen.add(vid)
|
|
483
|
+
return len(seen)
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def maybe_capture_youtube_cookies(
|
|
487
|
+
plan: CoursePlan, cookies_path: Path,
|
|
488
|
+
*, interactive: bool = True, input_fn=None,
|
|
489
|
+
) -> bool:
|
|
490
|
+
"""If the plan has YouTube items but no cookies file, offer a one-shot
|
|
491
|
+
browser login that writes cookies into `cookies_path`.
|
|
492
|
+
|
|
493
|
+
Returns True if cookies are now in place (either already-existed or just
|
|
494
|
+
captured), False if user declined or capture failed. Caller can use this
|
|
495
|
+
to decide whether to print the "unlisted videos may fail" warning.
|
|
496
|
+
|
|
497
|
+
No-op when:
|
|
498
|
+
- cookies file already exists (don't re-prompt every run)
|
|
499
|
+
- stdin isn't a TTY (piped scripts, CI, etc.)
|
|
500
|
+
- the plan has no YouTube items at all
|
|
501
|
+
- interactive=False (callers that want to suppress the prompt entirely)
|
|
502
|
+
|
|
503
|
+
The browser flow is delegated to `youtube_cookies.export_youtube_cookies`,
|
|
504
|
+
which is also exposed as the standalone `youtube-cookies` CLI. Same code
|
|
505
|
+
path, same default profile dir — so cookies captured here are reused by
|
|
506
|
+
the standalone command and vice-versa.
|
|
507
|
+
"""
|
|
508
|
+
if cookies_path.exists():
|
|
509
|
+
return True
|
|
510
|
+
if not interactive:
|
|
511
|
+
return False
|
|
512
|
+
if input_fn is None:
|
|
513
|
+
if not sys.stdin.isatty():
|
|
514
|
+
return False
|
|
515
|
+
input_fn = input
|
|
516
|
+
url_count = _count_youtube_urls_in_plan(plan)
|
|
517
|
+
if url_count == 0:
|
|
518
|
+
return False
|
|
519
|
+
|
|
520
|
+
prompt = t(
|
|
521
|
+
f" ❓ 找到 {url_count} 個 YouTube 影片連結。\n"
|
|
522
|
+
f" 公開影片不用登入。要不要先登入 YouTube,以下載私人 / 限齡 / 會員影片? [y/N]: ",
|
|
523
|
+
f" ❓ Found {url_count} YouTube link(s).\n"
|
|
524
|
+
f" Public videos don't need login. Log in to YouTube to enable "
|
|
525
|
+
f"private/age-restricted/member videos? [y/N]: ",
|
|
526
|
+
)
|
|
527
|
+
try:
|
|
528
|
+
answer = input_fn(prompt).strip().lower()
|
|
529
|
+
except (EOFError, KeyboardInterrupt):
|
|
530
|
+
return False
|
|
531
|
+
if answer not in {"y", "yes"}:
|
|
532
|
+
return False
|
|
533
|
+
|
|
534
|
+
try:
|
|
535
|
+
from .youtube_cookies import export_youtube_cookies
|
|
536
|
+
print(t(
|
|
537
|
+
" → 開啟瀏覽器登入 YouTube(登入後會自動匯出 cookies)...",
|
|
538
|
+
" → opening browser for YouTube login (cookies export on detection)...",
|
|
539
|
+
))
|
|
540
|
+
result = export_youtube_cookies(cookies_path=cookies_path, wait_for_login=True)
|
|
541
|
+
print(t(
|
|
542
|
+
f" ✓ 匯出 {result.cookie_count} 個 cookies → {cookies_path}",
|
|
543
|
+
f" ✓ exported {result.cookie_count} cookies → {cookies_path}",
|
|
544
|
+
))
|
|
545
|
+
return True
|
|
546
|
+
except Exception as exc:
|
|
547
|
+
print(t(
|
|
548
|
+
f" ⚠ 抓 YouTube cookies 失敗 ({exc})。改用公開模式繼續。",
|
|
549
|
+
f" ⚠ YouTube cookie capture failed ({exc}). Proceeding in public mode.",
|
|
550
|
+
))
|
|
551
|
+
return False
|
|
552
|
+
|
|
553
|
+
|
|
454
554
|
def download_youtube(plan: CoursePlan, *, cookies_path: Path, yt_dlp: str = "yt-dlp") -> StageStats:
|
|
455
555
|
"""For each week with YouTube items, run yt-dlp into the week dir then rename."""
|
|
456
556
|
import platform
|
|
@@ -524,13 +624,14 @@ def download_youtube(plan: CoursePlan, *, cookies_path: Path, yt_dlp: str = "yt-
|
|
|
524
624
|
print(f" [{week.label}/youtube] 開始下載 {len(urls)} 個 URL (缺少 {len(missing)} 個)")
|
|
525
625
|
cmd = [
|
|
526
626
|
yt_dlp, *YT_DLP_BASE_ARGS,
|
|
527
|
-
"--cookies", str(cookies_path),
|
|
528
627
|
# No --newline: let yt-dlp use \r so the progress bar refreshes
|
|
529
628
|
# in place on a single line instead of spamming the terminal.
|
|
530
629
|
"-P", str(videos_dir),
|
|
531
630
|
"-o", "%(id)s_%(title).120s.%(ext)s",
|
|
532
631
|
"-a", str(urls_file),
|
|
533
632
|
]
|
|
633
|
+
if cookies_path.exists():
|
|
634
|
+
cmd[1:1] = ["--cookies", str(cookies_path)]
|
|
534
635
|
try:
|
|
535
636
|
result = subprocess.run(cmd)
|
|
536
637
|
finally:
|
|
@@ -804,7 +905,7 @@ def _build_session_client_from_file(headers_path: Path, base_url: str) -> Canvas
|
|
|
804
905
|
return CanvasSessionClient(base_url=base_url, headers=read_headers_file(headers_path))
|
|
805
906
|
|
|
806
907
|
|
|
807
|
-
def _write_course_overview(plan: CoursePlan) -> Path:
|
|
908
|
+
def _write_course_overview(plan: CoursePlan, *, all_file_types: bool = False) -> Path:
|
|
808
909
|
"""Write a Markdown index at the course root listing every downloaded artifact per week.
|
|
809
910
|
Designed to be readable by both humans and AI tools."""
|
|
810
911
|
from datetime import datetime, timezone
|
|
@@ -829,10 +930,15 @@ def _write_course_overview(plan: CoursePlan) -> Path:
|
|
|
829
930
|
kind = item.get("type")
|
|
830
931
|
title = str(item.get("title") or "").strip()
|
|
831
932
|
if kind == "File":
|
|
832
|
-
|
|
833
|
-
fname = f"{sanitize_teacher_title(stem)}.pdf"
|
|
933
|
+
fname = _file_item_target_name(item, all_file_types=all_file_types)
|
|
834
934
|
if (week.week_dir / fname).exists():
|
|
835
|
-
|
|
935
|
+
# In default mode every file is .pdf on disk, so the dual
|
|
936
|
+
# icon only matters under --all-file-types where extensions
|
|
937
|
+
# are preserved. Either way we route by what's actually
|
|
938
|
+
# on disk, not by what the source extension said.
|
|
939
|
+
icon = "📄" if Path(fname).suffix.lower() == ".pdf" else "📎"
|
|
940
|
+
bucket = "pdf" if Path(fname).suffix.lower() == ".pdf" else "other"
|
|
941
|
+
per_type[bucket].append(f"- {icon} [{title}]({week.label}/{urllib.parse.quote(fname)})")
|
|
836
942
|
elif kind == "Page":
|
|
837
943
|
fname = f"{sanitize_teacher_title(title)}.md"
|
|
838
944
|
if (week.week_dir / fname).exists():
|
|
@@ -966,13 +1072,19 @@ def download_course(
|
|
|
966
1072
|
))
|
|
967
1073
|
if not skip_youtube:
|
|
968
1074
|
print(t("\n[3/4] YouTube 影片", "\n[3/4] YouTube videos"))
|
|
969
|
-
|
|
1075
|
+
yt_cookies_path = yt_cookies or Path(".secrets/youtube_cookies.txt")
|
|
1076
|
+
# Offer in-pipeline cookie capture if there are YouTube items but
|
|
1077
|
+
# no cookies on disk yet. User can decline; we proceed in public
|
|
1078
|
+
# mode either way. Skipped when stdin isn't a TTY (so this never
|
|
1079
|
+
# blocks scripted / CI invocations).
|
|
1080
|
+
have_cookies = maybe_capture_youtube_cookies(plan, yt_cookies_path)
|
|
1081
|
+
if not have_cookies:
|
|
970
1082
|
print(t(
|
|
971
|
-
f" 注意:
|
|
972
|
-
f"
|
|
1083
|
+
f" 注意: 沒有 YouTube cookies,不公開 / 限齡 / 會員影片可能下載失敗",
|
|
1084
|
+
f" Note: no YouTube cookies — private / age-restricted / member videos may fail",
|
|
973
1085
|
))
|
|
974
1086
|
course_stats.youtube = download_youtube(
|
|
975
|
-
plan, cookies_path=
|
|
1087
|
+
plan, cookies_path=yt_cookies_path, yt_dlp=yt_dlp
|
|
976
1088
|
)
|
|
977
1089
|
|
|
978
1090
|
if not skip_cool_videos:
|
|
@@ -990,7 +1102,7 @@ def download_course(
|
|
|
990
1102
|
|
|
991
1103
|
# Per-course overview at the course root.
|
|
992
1104
|
try:
|
|
993
|
-
overview_path = _write_course_overview(plan)
|
|
1105
|
+
overview_path = _write_course_overview(plan, all_file_types=all_file_types)
|
|
994
1106
|
print(t(f"\n 目錄: {overview_path.name}", f"\n overview: {overview_path.name}"))
|
|
995
1107
|
except Exception as exc:
|
|
996
1108
|
print(t(f"\n (無法產生目錄: {exc})", f"\n (could not write overview: {exc})"))
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "get-class-material"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.8"
|
|
8
8
|
description = "One-command bulk downloader for NTU COOL (Canvas) course materials — PDFs, lecture videos, and Pages."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
"""Tests for course_pipeline._ensure_logged_in.
|
|
2
|
+
|
|
3
|
+
These mock the Playwright Page object entirely — no actual browser is
|
|
4
|
+
launched. The function under test is a thin policy layer over Playwright,
|
|
5
|
+
so what matters is that we route correctly for each combination of
|
|
6
|
+
(goto outcome, current URL) we might see during a real SSO redirect.
|
|
7
|
+
|
|
8
|
+
Regression covered: an earlier version used `wait_until="domcontentloaded"`
|
|
9
|
+
with a 120s timeout; NTU's SAML chain plus a slow user typing credentials
|
|
10
|
+
sometimes never fires DOM-ready inside 120s, and the function crashed
|
|
11
|
+
instead of falling through to wait_for_url's 600s budget.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import contextlib
|
|
16
|
+
import io
|
|
17
|
+
import tempfile
|
|
18
|
+
import unittest
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from unittest import mock
|
|
21
|
+
|
|
22
|
+
from playwright.sync_api import TimeoutError as PWTimeout
|
|
23
|
+
|
|
24
|
+
from ntu_cool_materials import course_pipeline
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _make_page(*, url: str, goto_raises: Exception | None = None,
|
|
28
|
+
wait_for_url_raises: Exception | None = None,
|
|
29
|
+
url_after_wait: str | None = None):
|
|
30
|
+
"""Build a mock Page with the URL state at each step of the flow.
|
|
31
|
+
|
|
32
|
+
`url` is what page.url reports when first read (after goto returns).
|
|
33
|
+
`url_after_wait` is what page.url reports after wait_for_url returns —
|
|
34
|
+
simulates the user finishing SSO. Defaults to keeping the same URL.
|
|
35
|
+
"""
|
|
36
|
+
page = mock.MagicMock()
|
|
37
|
+
|
|
38
|
+
# page.url is a property that can change over time. Use PropertyMock so
|
|
39
|
+
# we can swap the underlying value mid-test.
|
|
40
|
+
url_state = {"value": url}
|
|
41
|
+
|
|
42
|
+
def get_url():
|
|
43
|
+
return url_state["value"]
|
|
44
|
+
|
|
45
|
+
type(page).url = mock.PropertyMock(side_effect=get_url)
|
|
46
|
+
|
|
47
|
+
if goto_raises is not None:
|
|
48
|
+
page.goto.side_effect = goto_raises
|
|
49
|
+
else:
|
|
50
|
+
page.goto.return_value = None
|
|
51
|
+
|
|
52
|
+
def _wait_for_url(*args, **kwargs):
|
|
53
|
+
if wait_for_url_raises is not None:
|
|
54
|
+
raise wait_for_url_raises
|
|
55
|
+
if url_after_wait is not None:
|
|
56
|
+
url_state["value"] = url_after_wait
|
|
57
|
+
|
|
58
|
+
page.wait_for_url.side_effect = _wait_for_url
|
|
59
|
+
return page
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class EnsureLoggedInTests(unittest.TestCase):
|
|
63
|
+
def setUp(self) -> None:
|
|
64
|
+
# `_ensure_logged_in` print()s status updates in Traditional Chinese.
|
|
65
|
+
# The CLI normally calls _force_utf8_streams() to reconfigure stdout
|
|
66
|
+
# before any such print, but unit tests bypass that path — and the
|
|
67
|
+
# GitHub Windows runner ships with cp1252 stdout, which can't encode
|
|
68
|
+
# CJK characters. Redirect stdout to a StringIO so the prints happen
|
|
69
|
+
# against an in-memory UTF-8-capable stream. (No assertion looks at
|
|
70
|
+
# captured output; we only care about the function's return value
|
|
71
|
+
# and which Playwright methods it called.)
|
|
72
|
+
self._stdout_ctx = contextlib.redirect_stdout(io.StringIO())
|
|
73
|
+
self._stdout_ctx.__enter__()
|
|
74
|
+
self.addCleanup(self._stdout_ctx.__exit__, None, None, None)
|
|
75
|
+
|
|
76
|
+
def test_already_logged_in_returns_true(self) -> None:
|
|
77
|
+
"""Happy path: cookies are fresh, goto lands directly on /courses."""
|
|
78
|
+
page = _make_page(url="https://cool.ntu.edu.tw/courses")
|
|
79
|
+
ok = course_pipeline._ensure_logged_in(page, course_id=None, sso_timeout_sec=600)
|
|
80
|
+
self.assertTrue(ok)
|
|
81
|
+
page.goto.assert_called_once()
|
|
82
|
+
page.wait_for_url.assert_not_called()
|
|
83
|
+
|
|
84
|
+
def test_redirects_to_sso_then_user_logs_in(self) -> None:
|
|
85
|
+
"""Stale cookies: goto lands on SSO, wait_for_url returns once the user
|
|
86
|
+
finishes typing creds. Function returns True."""
|
|
87
|
+
page = _make_page(
|
|
88
|
+
url="https://web2.cc.ntu.edu.tw/saml/sso?...",
|
|
89
|
+
url_after_wait="https://cool.ntu.edu.tw/courses",
|
|
90
|
+
)
|
|
91
|
+
ok = course_pipeline._ensure_logged_in(page, course_id=None, sso_timeout_sec=600)
|
|
92
|
+
self.assertTrue(ok)
|
|
93
|
+
page.wait_for_url.assert_called_once()
|
|
94
|
+
|
|
95
|
+
def test_goto_timeout_but_already_on_sso_still_proceeds(self) -> None:
|
|
96
|
+
"""THE REGRESSION GUARD: goto raised TimeoutError because DOM never
|
|
97
|
+
fired inside its short budget, but the navigation DID commit to the
|
|
98
|
+
SSO page. wait_for_url should still run and succeed."""
|
|
99
|
+
page = _make_page(
|
|
100
|
+
url="https://web2.cc.ntu.edu.tw/saml/sso?...",
|
|
101
|
+
goto_raises=PWTimeout("Page.goto: Timeout 60000ms exceeded."),
|
|
102
|
+
url_after_wait="https://cool.ntu.edu.tw/courses",
|
|
103
|
+
)
|
|
104
|
+
ok = course_pipeline._ensure_logged_in(page, course_id=None, sso_timeout_sec=600)
|
|
105
|
+
self.assertTrue(ok, "must fall through to wait_for_url instead of bubbling TimeoutError")
|
|
106
|
+
page.wait_for_url.assert_called_once()
|
|
107
|
+
|
|
108
|
+
def test_goto_timeout_and_page_never_committed_returns_false(self) -> None:
|
|
109
|
+
"""Real network failure: goto timed out AND the page is still on
|
|
110
|
+
about:blank (didn't commit any navigation). Bail with a clear
|
|
111
|
+
error rather than waiting 10 minutes on a dead URL."""
|
|
112
|
+
page = _make_page(
|
|
113
|
+
url="about:blank",
|
|
114
|
+
goto_raises=PWTimeout("Page.goto: Timeout 60000ms exceeded."),
|
|
115
|
+
)
|
|
116
|
+
ok = course_pipeline._ensure_logged_in(page, course_id=None, sso_timeout_sec=600)
|
|
117
|
+
self.assertFalse(ok)
|
|
118
|
+
|
|
119
|
+
def test_sso_login_actually_times_out(self) -> None:
|
|
120
|
+
"""User opens the browser but never finishes SSO. wait_for_url
|
|
121
|
+
timeout should propagate as a False return, not crash."""
|
|
122
|
+
page = _make_page(
|
|
123
|
+
url="https://web2.cc.ntu.edu.tw/saml/sso?...",
|
|
124
|
+
wait_for_url_raises=PWTimeout("Page.wait_for_url: Timeout exceeded."),
|
|
125
|
+
)
|
|
126
|
+
ok = course_pipeline._ensure_logged_in(page, course_id=None, sso_timeout_sec=600)
|
|
127
|
+
self.assertFalse(ok)
|
|
128
|
+
|
|
129
|
+
def test_uses_course_specific_url_when_course_id_given(self) -> None:
|
|
130
|
+
"""When called from the bulk download pipeline with a specific course,
|
|
131
|
+
we should navigate to /courses/<id> rather than /courses."""
|
|
132
|
+
page = _make_page(url="https://cool.ntu.edu.tw/courses/60804")
|
|
133
|
+
course_pipeline._ensure_logged_in(page, course_id="60804", sso_timeout_sec=600)
|
|
134
|
+
called_url = page.goto.call_args.args[0]
|
|
135
|
+
self.assertIn("/courses/60804", called_url)
|
|
136
|
+
|
|
137
|
+
def test_uses_commit_wait_until_not_domcontentloaded(self) -> None:
|
|
138
|
+
"""Explicit regression guard against reverting to domcontentloaded.
|
|
139
|
+
The whole point of this fix is that we DON'T wait for DOM ready,
|
|
140
|
+
because SAML's redirect chain can take longer than any reasonable
|
|
141
|
+
timeout to settle into a stable DOM."""
|
|
142
|
+
page = _make_page(url="https://cool.ntu.edu.tw/courses")
|
|
143
|
+
course_pipeline._ensure_logged_in(page, course_id=None, sso_timeout_sec=600)
|
|
144
|
+
self.assertEqual(page.goto.call_args.kwargs.get("wait_until"), "commit")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class CourseFileHandlingTests(unittest.TestCase):
|
|
148
|
+
def setUp(self) -> None:
|
|
149
|
+
self._stdout_ctx = contextlib.redirect_stdout(io.StringIO())
|
|
150
|
+
self._stdout_ctx.__enter__()
|
|
151
|
+
self.addCleanup(self._stdout_ctx.__exit__, None, None, None)
|
|
152
|
+
|
|
153
|
+
def test_non_pdf_file_keeps_pdf_extension_by_default(self) -> None:
|
|
154
|
+
"""Default mode forces every download to land as .pdf on disk so the
|
|
155
|
+
output folder looks uniform for downstream AI tools. --all-file-types
|
|
156
|
+
opts into the real extension (.pptx in this case)."""
|
|
157
|
+
item = {
|
|
158
|
+
"id": 1,
|
|
159
|
+
"type": "File",
|
|
160
|
+
"title": "week1 slides.pptx",
|
|
161
|
+
"content_details": {"display_name": "week1 slides.pptx"},
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
self.assertEqual(
|
|
165
|
+
course_pipeline._file_item_target_name(item, all_file_types=False),
|
|
166
|
+
"week1 slides.pdf",
|
|
167
|
+
)
|
|
168
|
+
self.assertEqual(
|
|
169
|
+
course_pipeline._file_item_target_name(item, all_file_types=True),
|
|
170
|
+
"week1 slides.pptx",
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
def test_pdf_file_unchanged_in_both_modes(self) -> None:
|
|
174
|
+
"""A real PDF should produce the same filename regardless of mode —
|
|
175
|
+
guards against the .pdf.pdf double-extension regression."""
|
|
176
|
+
item = {
|
|
177
|
+
"id": 2,
|
|
178
|
+
"type": "File",
|
|
179
|
+
"title": "syllabus.pdf",
|
|
180
|
+
"content_details": {"display_name": "syllabus.pdf"},
|
|
181
|
+
}
|
|
182
|
+
self.assertEqual(
|
|
183
|
+
course_pipeline._file_item_target_name(item, all_file_types=False),
|
|
184
|
+
"syllabus.pdf",
|
|
185
|
+
)
|
|
186
|
+
self.assertEqual(
|
|
187
|
+
course_pipeline._file_item_target_name(item, all_file_types=True),
|
|
188
|
+
"syllabus.pdf",
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
def test_youtube_command_omits_missing_cookie_file(self) -> None:
|
|
192
|
+
with tempfile.TemporaryDirectory() as temp:
|
|
193
|
+
root = Path(temp)
|
|
194
|
+
plan = course_pipeline.CoursePlan(
|
|
195
|
+
course={"id": "1", "name": "Course"},
|
|
196
|
+
course_id="1",
|
|
197
|
+
course_dir=root,
|
|
198
|
+
weeks=[
|
|
199
|
+
course_pipeline.WeekPlan(
|
|
200
|
+
label="week1",
|
|
201
|
+
module={
|
|
202
|
+
"items": [
|
|
203
|
+
{
|
|
204
|
+
"id": 10,
|
|
205
|
+
"type": "ExternalUrl",
|
|
206
|
+
"title": "1-1 video",
|
|
207
|
+
"external_url": "https://www.youtube.com/watch?v=BT4w_3QsrQ8",
|
|
208
|
+
}
|
|
209
|
+
]
|
|
210
|
+
},
|
|
211
|
+
week_dir=root / "week1",
|
|
212
|
+
)
|
|
213
|
+
],
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
run_result = mock.Mock(returncode=0)
|
|
217
|
+
with (
|
|
218
|
+
mock.patch.object(course_pipeline.shutil, "which", return_value="tool"),
|
|
219
|
+
mock.patch.object(course_pipeline.subprocess, "run", return_value=run_result) as run,
|
|
220
|
+
mock.patch.object(course_pipeline, "rename_downloaded_videos", return_value=[]),
|
|
221
|
+
):
|
|
222
|
+
course_pipeline.download_youtube(
|
|
223
|
+
plan,
|
|
224
|
+
cookies_path=root / "missing-youtube-cookies.txt",
|
|
225
|
+
yt_dlp="yt-dlp",
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
cmd = run.call_args.args[0]
|
|
229
|
+
self.assertNotIn("--cookies", cmd)
|
|
230
|
+
|
|
231
|
+
def test_youtube_command_uses_cookie_file_when_present(self) -> None:
|
|
232
|
+
with tempfile.TemporaryDirectory() as temp:
|
|
233
|
+
root = Path(temp)
|
|
234
|
+
cookies = root / "youtube-cookies.txt"
|
|
235
|
+
cookies.write_text("# Netscape HTTP Cookie File\n", encoding="utf-8")
|
|
236
|
+
plan = course_pipeline.CoursePlan(
|
|
237
|
+
course={"id": "1", "name": "Course"},
|
|
238
|
+
course_id="1",
|
|
239
|
+
course_dir=root,
|
|
240
|
+
weeks=[
|
|
241
|
+
course_pipeline.WeekPlan(
|
|
242
|
+
label="week1",
|
|
243
|
+
module={
|
|
244
|
+
"items": [
|
|
245
|
+
{
|
|
246
|
+
"id": 10,
|
|
247
|
+
"type": "ExternalUrl",
|
|
248
|
+
"title": "1-1 video",
|
|
249
|
+
"external_url": "https://www.youtube.com/watch?v=BT4w_3QsrQ8",
|
|
250
|
+
}
|
|
251
|
+
]
|
|
252
|
+
},
|
|
253
|
+
week_dir=root / "week1",
|
|
254
|
+
)
|
|
255
|
+
],
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
run_result = mock.Mock(returncode=0)
|
|
259
|
+
with (
|
|
260
|
+
mock.patch.object(course_pipeline.shutil, "which", return_value="tool"),
|
|
261
|
+
mock.patch.object(course_pipeline.subprocess, "run", return_value=run_result) as run,
|
|
262
|
+
mock.patch.object(course_pipeline, "rename_downloaded_videos", return_value=[]),
|
|
263
|
+
):
|
|
264
|
+
course_pipeline.download_youtube(plan, cookies_path=cookies, yt_dlp="yt-dlp")
|
|
265
|
+
|
|
266
|
+
cmd = run.call_args.args[0]
|
|
267
|
+
self.assertIn("--cookies", cmd)
|
|
268
|
+
self.assertEqual(cmd[cmd.index("--cookies") + 1], str(cookies))
|
|
269
|
+
|
|
270
|
+
def test_course_overview_uses_real_extension_for_all_file_types(self) -> None:
|
|
271
|
+
with tempfile.TemporaryDirectory() as temp:
|
|
272
|
+
root = Path(temp)
|
|
273
|
+
week_dir = root / "week1"
|
|
274
|
+
week_dir.mkdir()
|
|
275
|
+
(week_dir / "week1 slides.pptx").write_bytes(b"pptx")
|
|
276
|
+
plan = course_pipeline.CoursePlan(
|
|
277
|
+
course={"id": "1", "name": "Course"},
|
|
278
|
+
course_id="1",
|
|
279
|
+
course_dir=root,
|
|
280
|
+
weeks=[
|
|
281
|
+
course_pipeline.WeekPlan(
|
|
282
|
+
label="week1",
|
|
283
|
+
module={
|
|
284
|
+
"name": "Week 1",
|
|
285
|
+
"items": [
|
|
286
|
+
{
|
|
287
|
+
"id": 1,
|
|
288
|
+
"type": "File",
|
|
289
|
+
"title": "week1 slides.pptx",
|
|
290
|
+
"content_details": {"display_name": "week1 slides.pptx"},
|
|
291
|
+
}
|
|
292
|
+
],
|
|
293
|
+
},
|
|
294
|
+
week_dir=week_dir,
|
|
295
|
+
)
|
|
296
|
+
],
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
overview = course_pipeline._write_course_overview(plan, all_file_types=True)
|
|
300
|
+
text = overview.read_text(encoding="utf-8")
|
|
301
|
+
self.assertIn("week1%20slides.pptx", text)
|
|
302
|
+
self.assertNotIn("week1%20slides.pdf", text)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
class MaybeCaptureYoutubeCookiesTests(unittest.TestCase):
|
|
306
|
+
"""Tests for the in-pipeline YouTube cookie capture prompt.
|
|
307
|
+
|
|
308
|
+
The function should be conservative: never prompt when cookies already
|
|
309
|
+
exist, never prompt for non-YouTube courses, never block CI/piped runs.
|
|
310
|
+
"""
|
|
311
|
+
|
|
312
|
+
def setUp(self) -> None:
|
|
313
|
+
self._stdout_ctx = contextlib.redirect_stdout(io.StringIO())
|
|
314
|
+
self._stdout_ctx.__enter__()
|
|
315
|
+
self.addCleanup(self._stdout_ctx.__exit__, None, None, None)
|
|
316
|
+
|
|
317
|
+
def _make_plan_with_youtube(self, urls: list[str]):
|
|
318
|
+
return course_pipeline.CoursePlan(
|
|
319
|
+
course={"id": "1", "name": "Course"},
|
|
320
|
+
course_id="1",
|
|
321
|
+
course_dir=Path("."),
|
|
322
|
+
weeks=[
|
|
323
|
+
course_pipeline.WeekPlan(
|
|
324
|
+
label="week1",
|
|
325
|
+
module={
|
|
326
|
+
"items": [
|
|
327
|
+
{"id": i, "type": "ExternalUrl", "title": f"vid{i}", "external_url": u}
|
|
328
|
+
for i, u in enumerate(urls, start=1)
|
|
329
|
+
]
|
|
330
|
+
},
|
|
331
|
+
week_dir=Path("./week1"),
|
|
332
|
+
)
|
|
333
|
+
],
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
def test_returns_true_when_cookies_already_exist(self) -> None:
|
|
337
|
+
"""Existing cookies = no prompt, no capture. Caller knows it's ok."""
|
|
338
|
+
with tempfile.TemporaryDirectory() as temp:
|
|
339
|
+
cookies = Path(temp) / "yt.txt"
|
|
340
|
+
cookies.write_text("# Netscape HTTP Cookie File\n", encoding="utf-8")
|
|
341
|
+
plan = self._make_plan_with_youtube(["https://youtu.be/dQw4w9WgXcQ"])
|
|
342
|
+
calls = []
|
|
343
|
+
result = course_pipeline.maybe_capture_youtube_cookies(
|
|
344
|
+
plan, cookies, input_fn=lambda p: calls.append(p) or "y",
|
|
345
|
+
)
|
|
346
|
+
self.assertTrue(result)
|
|
347
|
+
self.assertEqual(calls, [], "must not prompt when cookies file already present")
|
|
348
|
+
|
|
349
|
+
def test_no_prompt_when_plan_has_no_youtube(self) -> None:
|
|
350
|
+
"""Course with only PDFs / pages / cool-videos should not get bothered."""
|
|
351
|
+
plan = course_pipeline.CoursePlan(
|
|
352
|
+
course={"id": "1", "name": "Course"}, course_id="1", course_dir=Path("."),
|
|
353
|
+
weeks=[course_pipeline.WeekPlan(label="w1", module={"items": []}, week_dir=Path("./w1"))],
|
|
354
|
+
)
|
|
355
|
+
with tempfile.TemporaryDirectory() as temp:
|
|
356
|
+
cookies = Path(temp) / "yt.txt" # doesn't exist
|
|
357
|
+
calls = []
|
|
358
|
+
result = course_pipeline.maybe_capture_youtube_cookies(
|
|
359
|
+
plan, cookies, input_fn=lambda p: calls.append(p) or "y",
|
|
360
|
+
)
|
|
361
|
+
self.assertFalse(result)
|
|
362
|
+
self.assertEqual(calls, [])
|
|
363
|
+
|
|
364
|
+
def test_skipped_when_interactive_false(self) -> None:
|
|
365
|
+
"""interactive=False is the escape hatch for batch / CI callers."""
|
|
366
|
+
plan = self._make_plan_with_youtube(["https://youtu.be/dQw4w9WgXcQ"])
|
|
367
|
+
with tempfile.TemporaryDirectory() as temp:
|
|
368
|
+
cookies = Path(temp) / "yt.txt"
|
|
369
|
+
calls = []
|
|
370
|
+
result = course_pipeline.maybe_capture_youtube_cookies(
|
|
371
|
+
plan, cookies, interactive=False, input_fn=lambda p: calls.append(p) or "y",
|
|
372
|
+
)
|
|
373
|
+
self.assertFalse(result)
|
|
374
|
+
self.assertEqual(calls, [])
|
|
375
|
+
|
|
376
|
+
def test_user_declines_returns_false(self) -> None:
|
|
377
|
+
"""User presses Enter / N: no capture, function returns False so
|
|
378
|
+
caller prints the 'no cookies' warning."""
|
|
379
|
+
plan = self._make_plan_with_youtube(["https://youtu.be/dQw4w9WgXcQ"])
|
|
380
|
+
with tempfile.TemporaryDirectory() as temp:
|
|
381
|
+
cookies = Path(temp) / "yt.txt"
|
|
382
|
+
result = course_pipeline.maybe_capture_youtube_cookies(
|
|
383
|
+
plan, cookies, input_fn=lambda p: "n",
|
|
384
|
+
)
|
|
385
|
+
self.assertFalse(result)
|
|
386
|
+
self.assertFalse(cookies.exists())
|
|
387
|
+
|
|
388
|
+
def test_user_accepts_runs_export(self) -> None:
|
|
389
|
+
"""User says 'y': we delegate to youtube_cookies.export_youtube_cookies
|
|
390
|
+
with the right cookies_path. We mock that function to avoid spawning
|
|
391
|
+
a real browser; what we verify is the wiring."""
|
|
392
|
+
plan = self._make_plan_with_youtube(["https://youtu.be/dQw4w9WgXcQ", "https://youtu.be/oHg5SJYRHA0"])
|
|
393
|
+
with tempfile.TemporaryDirectory() as temp:
|
|
394
|
+
cookies = Path(temp) / "yt.txt"
|
|
395
|
+
|
|
396
|
+
captured_kwargs = {}
|
|
397
|
+
|
|
398
|
+
def fake_export(**kwargs):
|
|
399
|
+
captured_kwargs.update(kwargs)
|
|
400
|
+
# Simulate the real function's side effect: write the cookies file.
|
|
401
|
+
kwargs["cookies_path"].write_text("# fake cookies\n", encoding="utf-8")
|
|
402
|
+
return mock.Mock(cookies_path=kwargs["cookies_path"], cookie_count=42, current_url="x")
|
|
403
|
+
|
|
404
|
+
with mock.patch("ntu_cool_materials.youtube_cookies.export_youtube_cookies",
|
|
405
|
+
side_effect=fake_export):
|
|
406
|
+
result = course_pipeline.maybe_capture_youtube_cookies(
|
|
407
|
+
plan, cookies, input_fn=lambda p: "y",
|
|
408
|
+
)
|
|
409
|
+
self.assertTrue(result)
|
|
410
|
+
self.assertEqual(captured_kwargs.get("cookies_path"), cookies)
|
|
411
|
+
self.assertTrue(captured_kwargs.get("wait_for_login"))
|
|
412
|
+
|
|
413
|
+
def test_eof_during_prompt_returns_false(self) -> None:
|
|
414
|
+
"""User Ctrl-D'd through the prompt. Don't crash."""
|
|
415
|
+
plan = self._make_plan_with_youtube(["https://youtu.be/dQw4w9WgXcQ"])
|
|
416
|
+
def raising_input(_prompt):
|
|
417
|
+
raise EOFError
|
|
418
|
+
with tempfile.TemporaryDirectory() as temp:
|
|
419
|
+
cookies = Path(temp) / "yt.txt"
|
|
420
|
+
result = course_pipeline.maybe_capture_youtube_cookies(
|
|
421
|
+
plan, cookies, input_fn=raising_input,
|
|
422
|
+
)
|
|
423
|
+
self.assertFalse(result)
|
|
424
|
+
|
|
425
|
+
def test_export_failure_swallowed_returns_false(self) -> None:
|
|
426
|
+
"""If the browser flow blows up, we warn and proceed in public mode
|
|
427
|
+
instead of crashing the whole download_course run."""
|
|
428
|
+
plan = self._make_plan_with_youtube(["https://youtu.be/dQw4w9WgXcQ"])
|
|
429
|
+
with tempfile.TemporaryDirectory() as temp:
|
|
430
|
+
cookies = Path(temp) / "yt.txt"
|
|
431
|
+
with mock.patch("ntu_cool_materials.youtube_cookies.export_youtube_cookies",
|
|
432
|
+
side_effect=RuntimeError("playwright went boom")):
|
|
433
|
+
result = course_pipeline.maybe_capture_youtube_cookies(
|
|
434
|
+
plan, cookies, input_fn=lambda p: "y",
|
|
435
|
+
)
|
|
436
|
+
self.assertFalse(result)
|
|
437
|
+
self.assertFalse(cookies.exists())
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
class CountYoutubeUrlsInPlanTests(unittest.TestCase):
|
|
441
|
+
def test_counts_distinct_video_ids(self) -> None:
|
|
442
|
+
"""Same video appearing twice should count once.
|
|
443
|
+
|
|
444
|
+
Note: YouTube IDs are exactly 11 chars of [A-Za-z0-9_-] — short fake
|
|
445
|
+
IDs like 'abc' won't match extract_youtube_ids' regex. Use full-length
|
|
446
|
+
fixtures so the test exercises the real code path."""
|
|
447
|
+
VID_A = "dQw4w9WgXcQ" # 11 chars
|
|
448
|
+
VID_B = "oHg5SJYRHA0" # 11 chars, distinct
|
|
449
|
+
plan = course_pipeline.CoursePlan(
|
|
450
|
+
course={"id": "1", "name": "Course"}, course_id="1", course_dir=Path("."),
|
|
451
|
+
weeks=[
|
|
452
|
+
course_pipeline.WeekPlan(
|
|
453
|
+
label="w1",
|
|
454
|
+
module={"items": [
|
|
455
|
+
{"id": 1, "type": "ExternalUrl", "title": "v1", "external_url": f"https://youtu.be/{VID_A}"},
|
|
456
|
+
# Same video via different URL form — should dedupe.
|
|
457
|
+
{"id": 2, "type": "ExternalUrl", "title": "v2", "external_url": f"https://www.youtube.com/watch?v={VID_A}"},
|
|
458
|
+
{"id": 3, "type": "ExternalUrl", "title": "v3", "external_url": f"https://youtu.be/{VID_B}"},
|
|
459
|
+
]},
|
|
460
|
+
week_dir=Path("./w1"),
|
|
461
|
+
)
|
|
462
|
+
],
|
|
463
|
+
)
|
|
464
|
+
self.assertEqual(course_pipeline._count_youtube_urls_in_plan(plan), 2)
|
|
465
|
+
|
|
466
|
+
def test_ignores_non_youtube_external_urls(self) -> None:
|
|
467
|
+
plan = course_pipeline.CoursePlan(
|
|
468
|
+
course={"id": "1", "name": "Course"}, course_id="1", course_dir=Path("."),
|
|
469
|
+
weeks=[
|
|
470
|
+
course_pipeline.WeekPlan(
|
|
471
|
+
label="w1",
|
|
472
|
+
module={"items": [
|
|
473
|
+
{"id": 1, "type": "ExternalUrl", "title": "doc", "external_url": "https://drive.google.com/file/d/xyz"},
|
|
474
|
+
{"id": 2, "type": "File", "title": "slides.pdf"},
|
|
475
|
+
]},
|
|
476
|
+
week_dir=Path("./w1"),
|
|
477
|
+
)
|
|
478
|
+
],
|
|
479
|
+
)
|
|
480
|
+
self.assertEqual(course_pipeline._count_youtube_urls_in_plan(plan), 0)
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
if __name__ == "__main__":
|
|
484
|
+
unittest.main()
|
|
@@ -1,146 +0,0 @@
|
|
|
1
|
-
"""Tests for course_pipeline._ensure_logged_in.
|
|
2
|
-
|
|
3
|
-
These mock the Playwright Page object entirely — no actual browser is
|
|
4
|
-
launched. The function under test is a thin policy layer over Playwright,
|
|
5
|
-
so what matters is that we route correctly for each combination of
|
|
6
|
-
(goto outcome, current URL) we might see during a real SSO redirect.
|
|
7
|
-
|
|
8
|
-
Regression covered: an earlier version used `wait_until="domcontentloaded"`
|
|
9
|
-
with a 120s timeout; NTU's SAML chain plus a slow user typing credentials
|
|
10
|
-
sometimes never fires DOM-ready inside 120s, and the function crashed
|
|
11
|
-
instead of falling through to wait_for_url's 600s budget.
|
|
12
|
-
"""
|
|
13
|
-
from __future__ import annotations
|
|
14
|
-
|
|
15
|
-
import contextlib
|
|
16
|
-
import io
|
|
17
|
-
import unittest
|
|
18
|
-
from unittest import mock
|
|
19
|
-
|
|
20
|
-
from playwright.sync_api import TimeoutError as PWTimeout
|
|
21
|
-
|
|
22
|
-
from ntu_cool_materials import course_pipeline
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
def _make_page(*, url: str, goto_raises: Exception | None = None,
|
|
26
|
-
wait_for_url_raises: Exception | None = None,
|
|
27
|
-
url_after_wait: str | None = None):
|
|
28
|
-
"""Build a mock Page with the URL state at each step of the flow.
|
|
29
|
-
|
|
30
|
-
`url` is what page.url reports when first read (after goto returns).
|
|
31
|
-
`url_after_wait` is what page.url reports after wait_for_url returns —
|
|
32
|
-
simulates the user finishing SSO. Defaults to keeping the same URL.
|
|
33
|
-
"""
|
|
34
|
-
page = mock.MagicMock()
|
|
35
|
-
|
|
36
|
-
# page.url is a property that can change over time. Use PropertyMock so
|
|
37
|
-
# we can swap the underlying value mid-test.
|
|
38
|
-
url_state = {"value": url}
|
|
39
|
-
|
|
40
|
-
def get_url():
|
|
41
|
-
return url_state["value"]
|
|
42
|
-
|
|
43
|
-
type(page).url = mock.PropertyMock(side_effect=get_url)
|
|
44
|
-
|
|
45
|
-
if goto_raises is not None:
|
|
46
|
-
page.goto.side_effect = goto_raises
|
|
47
|
-
else:
|
|
48
|
-
page.goto.return_value = None
|
|
49
|
-
|
|
50
|
-
def _wait_for_url(*args, **kwargs):
|
|
51
|
-
if wait_for_url_raises is not None:
|
|
52
|
-
raise wait_for_url_raises
|
|
53
|
-
if url_after_wait is not None:
|
|
54
|
-
url_state["value"] = url_after_wait
|
|
55
|
-
|
|
56
|
-
page.wait_for_url.side_effect = _wait_for_url
|
|
57
|
-
return page
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
class EnsureLoggedInTests(unittest.TestCase):
|
|
61
|
-
def setUp(self) -> None:
|
|
62
|
-
# `_ensure_logged_in` print()s status updates in Traditional Chinese.
|
|
63
|
-
# The CLI normally calls _force_utf8_streams() to reconfigure stdout
|
|
64
|
-
# before any such print, but unit tests bypass that path — and the
|
|
65
|
-
# GitHub Windows runner ships with cp1252 stdout, which can't encode
|
|
66
|
-
# CJK characters. Redirect stdout to a StringIO so the prints happen
|
|
67
|
-
# against an in-memory UTF-8-capable stream. (No assertion looks at
|
|
68
|
-
# captured output; we only care about the function's return value
|
|
69
|
-
# and which Playwright methods it called.)
|
|
70
|
-
self._stdout_ctx = contextlib.redirect_stdout(io.StringIO())
|
|
71
|
-
self._stdout_ctx.__enter__()
|
|
72
|
-
self.addCleanup(self._stdout_ctx.__exit__, None, None, None)
|
|
73
|
-
|
|
74
|
-
def test_already_logged_in_returns_true(self) -> None:
|
|
75
|
-
"""Happy path: cookies are fresh, goto lands directly on /courses."""
|
|
76
|
-
page = _make_page(url="https://cool.ntu.edu.tw/courses")
|
|
77
|
-
ok = course_pipeline._ensure_logged_in(page, course_id=None, sso_timeout_sec=600)
|
|
78
|
-
self.assertTrue(ok)
|
|
79
|
-
page.goto.assert_called_once()
|
|
80
|
-
page.wait_for_url.assert_not_called()
|
|
81
|
-
|
|
82
|
-
def test_redirects_to_sso_then_user_logs_in(self) -> None:
|
|
83
|
-
"""Stale cookies: goto lands on SSO, wait_for_url returns once the user
|
|
84
|
-
finishes typing creds. Function returns True."""
|
|
85
|
-
page = _make_page(
|
|
86
|
-
url="https://web2.cc.ntu.edu.tw/saml/sso?...",
|
|
87
|
-
url_after_wait="https://cool.ntu.edu.tw/courses",
|
|
88
|
-
)
|
|
89
|
-
ok = course_pipeline._ensure_logged_in(page, course_id=None, sso_timeout_sec=600)
|
|
90
|
-
self.assertTrue(ok)
|
|
91
|
-
page.wait_for_url.assert_called_once()
|
|
92
|
-
|
|
93
|
-
def test_goto_timeout_but_already_on_sso_still_proceeds(self) -> None:
|
|
94
|
-
"""THE REGRESSION GUARD: goto raised TimeoutError because DOM never
|
|
95
|
-
fired inside its short budget, but the navigation DID commit to the
|
|
96
|
-
SSO page. wait_for_url should still run and succeed."""
|
|
97
|
-
page = _make_page(
|
|
98
|
-
url="https://web2.cc.ntu.edu.tw/saml/sso?...",
|
|
99
|
-
goto_raises=PWTimeout("Page.goto: Timeout 60000ms exceeded."),
|
|
100
|
-
url_after_wait="https://cool.ntu.edu.tw/courses",
|
|
101
|
-
)
|
|
102
|
-
ok = course_pipeline._ensure_logged_in(page, course_id=None, sso_timeout_sec=600)
|
|
103
|
-
self.assertTrue(ok, "must fall through to wait_for_url instead of bubbling TimeoutError")
|
|
104
|
-
page.wait_for_url.assert_called_once()
|
|
105
|
-
|
|
106
|
-
def test_goto_timeout_and_page_never_committed_returns_false(self) -> None:
|
|
107
|
-
"""Real network failure: goto timed out AND the page is still on
|
|
108
|
-
about:blank (didn't commit any navigation). Bail with a clear
|
|
109
|
-
error rather than waiting 10 minutes on a dead URL."""
|
|
110
|
-
page = _make_page(
|
|
111
|
-
url="about:blank",
|
|
112
|
-
goto_raises=PWTimeout("Page.goto: Timeout 60000ms exceeded."),
|
|
113
|
-
)
|
|
114
|
-
ok = course_pipeline._ensure_logged_in(page, course_id=None, sso_timeout_sec=600)
|
|
115
|
-
self.assertFalse(ok)
|
|
116
|
-
|
|
117
|
-
def test_sso_login_actually_times_out(self) -> None:
|
|
118
|
-
"""User opens the browser but never finishes SSO. wait_for_url
|
|
119
|
-
timeout should propagate as a False return, not crash."""
|
|
120
|
-
page = _make_page(
|
|
121
|
-
url="https://web2.cc.ntu.edu.tw/saml/sso?...",
|
|
122
|
-
wait_for_url_raises=PWTimeout("Page.wait_for_url: Timeout exceeded."),
|
|
123
|
-
)
|
|
124
|
-
ok = course_pipeline._ensure_logged_in(page, course_id=None, sso_timeout_sec=600)
|
|
125
|
-
self.assertFalse(ok)
|
|
126
|
-
|
|
127
|
-
def test_uses_course_specific_url_when_course_id_given(self) -> None:
|
|
128
|
-
"""When called from the bulk download pipeline with a specific course,
|
|
129
|
-
we should navigate to /courses/<id> rather than /courses."""
|
|
130
|
-
page = _make_page(url="https://cool.ntu.edu.tw/courses/60804")
|
|
131
|
-
course_pipeline._ensure_logged_in(page, course_id="60804", sso_timeout_sec=600)
|
|
132
|
-
called_url = page.goto.call_args.args[0]
|
|
133
|
-
self.assertIn("/courses/60804", called_url)
|
|
134
|
-
|
|
135
|
-
def test_uses_commit_wait_until_not_domcontentloaded(self) -> None:
|
|
136
|
-
"""Explicit regression guard against reverting to domcontentloaded.
|
|
137
|
-
The whole point of this fix is that we DON'T wait for DOM ready,
|
|
138
|
-
because SAML's redirect chain can take longer than any reasonable
|
|
139
|
-
timeout to settle into a stable DOM."""
|
|
140
|
-
page = _make_page(url="https://cool.ntu.edu.tw/courses")
|
|
141
|
-
course_pipeline._ensure_logged_in(page, course_id=None, sso_timeout_sec=600)
|
|
142
|
-
self.assertEqual(page.goto.call_args.kwargs.get("wait_until"), "commit")
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
if __name__ == "__main__":
|
|
146
|
-
unittest.main()
|
|
File without changes
|
{get_class_material-0.2.7 → get_class_material-0.2.8}/get_class_material.egg-info/SOURCES.txt
RENAMED
|
File without changes
|
|
File without changes
|
{get_class_material-0.2.7 → get_class_material-0.2.8}/get_class_material.egg-info/entry_points.txt
RENAMED
|
File without changes
|
{get_class_material-0.2.7 → get_class_material-0.2.8}/get_class_material.egg-info/requires.txt
RENAMED
|
File without changes
|
{get_class_material-0.2.7 → get_class_material-0.2.8}/get_class_material.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|