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.
Files changed (40) hide show
  1. {get_class_material-0.2.7 → get_class_material-0.2.8}/PKG-INFO +24 -6
  2. {get_class_material-0.2.7 → get_class_material-0.2.8}/README.md +23 -5
  3. {get_class_material-0.2.7 → get_class_material-0.2.8}/get_class_material.egg-info/PKG-INFO +24 -6
  4. {get_class_material-0.2.7 → get_class_material-0.2.8}/ntu_cool_materials/course_pipeline.py +147 -35
  5. {get_class_material-0.2.7 → get_class_material-0.2.8}/pyproject.toml +1 -1
  6. get_class_material-0.2.8/tests/test_course_pipeline.py +484 -0
  7. get_class_material-0.2.7/tests/test_course_pipeline.py +0 -146
  8. {get_class_material-0.2.7 → get_class_material-0.2.8}/LICENSE +0 -0
  9. {get_class_material-0.2.7 → get_class_material-0.2.8}/get_class_material.egg-info/SOURCES.txt +0 -0
  10. {get_class_material-0.2.7 → get_class_material-0.2.8}/get_class_material.egg-info/dependency_links.txt +0 -0
  11. {get_class_material-0.2.7 → get_class_material-0.2.8}/get_class_material.egg-info/entry_points.txt +0 -0
  12. {get_class_material-0.2.7 → get_class_material-0.2.8}/get_class_material.egg-info/requires.txt +0 -0
  13. {get_class_material-0.2.7 → get_class_material-0.2.8}/get_class_material.egg-info/top_level.txt +0 -0
  14. {get_class_material-0.2.7 → get_class_material-0.2.8}/ntu_cool_materials/__init__.py +0 -0
  15. {get_class_material-0.2.7 → get_class_material-0.2.8}/ntu_cool_materials/__main__.py +0 -0
  16. {get_class_material-0.2.7 → get_class_material-0.2.8}/ntu_cool_materials/announcements.py +0 -0
  17. {get_class_material-0.2.7 → get_class_material-0.2.8}/ntu_cool_materials/browser_session.py +0 -0
  18. {get_class_material-0.2.7 → get_class_material-0.2.8}/ntu_cool_materials/canvas_client.py +0 -0
  19. {get_class_material-0.2.7 → get_class_material-0.2.8}/ntu_cool_materials/cli.py +0 -0
  20. {get_class_material-0.2.7 → get_class_material-0.2.8}/ntu_cool_materials/cool_video.py +0 -0
  21. {get_class_material-0.2.7 → get_class_material-0.2.8}/ntu_cool_materials/doctor.py +0 -0
  22. {get_class_material-0.2.7 → get_class_material-0.2.8}/ntu_cool_materials/i18n.py +0 -0
  23. {get_class_material-0.2.7 → get_class_material-0.2.8}/ntu_cool_materials/media_naming.py +0 -0
  24. {get_class_material-0.2.7 → get_class_material-0.2.8}/ntu_cool_materials/pages.py +0 -0
  25. {get_class_material-0.2.7 → get_class_material-0.2.8}/ntu_cool_materials/session_client.py +0 -0
  26. {get_class_material-0.2.7 → get_class_material-0.2.8}/ntu_cool_materials/storage.py +0 -0
  27. {get_class_material-0.2.7 → get_class_material-0.2.8}/ntu_cool_materials/sync.py +0 -0
  28. {get_class_material-0.2.7 → get_class_material-0.2.8}/ntu_cool_materials/text_extract.py +0 -0
  29. {get_class_material-0.2.7 → get_class_material-0.2.8}/ntu_cool_materials/youtube_cookies.py +0 -0
  30. {get_class_material-0.2.7 → get_class_material-0.2.8}/setup.cfg +0 -0
  31. {get_class_material-0.2.7 → get_class_material-0.2.8}/tests/test_announcements.py +0 -0
  32. {get_class_material-0.2.7 → get_class_material-0.2.8}/tests/test_browser_session.py +0 -0
  33. {get_class_material-0.2.7 → get_class_material-0.2.8}/tests/test_canvas_client.py +0 -0
  34. {get_class_material-0.2.7 → get_class_material-0.2.8}/tests/test_cli.py +0 -0
  35. {get_class_material-0.2.7 → get_class_material-0.2.8}/tests/test_cool_video.py +0 -0
  36. {get_class_material-0.2.7 → get_class_material-0.2.8}/tests/test_doctor.py +0 -0
  37. {get_class_material-0.2.7 → get_class_material-0.2.8}/tests/test_media_naming.py +0 -0
  38. {get_class_material-0.2.7 → get_class_material-0.2.8}/tests/test_session_client.py +0 -0
  39. {get_class_material-0.2.7 → get_class_material-0.2.8}/tests/test_storage.py +0 -0
  40. {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.7
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
- 預設是當前目錄底下的 `ntu-cool-gcm_material/`。
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
- 預設在你**執行 `ntu-cool-gcm` 時所在的目錄**底下,叫 `ntu-cool-gcm_material/`。
395
+ 預設在你的「文件 / Documents」資料夾底下,叫 `ntu-cool-gcm_material/`。
396
+
397
+ 常見位置像這樣:
398
+
399
+ ```text
400
+ C:\Users\你\Documents\ntu-cool-gcm_material\
401
+ ```
384
402
 
385
- 如果你在 PowerShell 看到提示是 `PS C:\Users\你>`,那就是 `C:\Users\你\ntu-cool-gcm_material\`。
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
- 預設是當前目錄底下的 `ntu-cool-gcm_material/`。
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
- 預設在你**執行 `ntu-cool-gcm` 時所在的目錄**底下,叫 `ntu-cool-gcm_material/`。
363
+ 預設在你的「文件 / Documents」資料夾底下,叫 `ntu-cool-gcm_material/`。
364
+
365
+ 常見位置像這樣:
366
+
367
+ ```text
368
+ C:\Users\你\Documents\ntu-cool-gcm_material\
369
+ ```
352
370
 
353
- 如果你在 PowerShell 看到提示是 `PS C:\Users\你>`,那就是 `C:\Users\你\ntu-cool-gcm_material\`。
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.7
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
- 預設是當前目錄底下的 `ntu-cool-gcm_material/`。
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
- 預設在你**執行 `ntu-cool-gcm` 時所在的目錄**底下,叫 `ntu-cool-gcm_material/`。
395
+ 預設在你的「文件 / Documents」資料夾底下,叫 `ntu-cool-gcm_material/`。
396
+
397
+ 常見位置像這樣:
398
+
399
+ ```text
400
+ C:\Users\你\Documents\ntu-cool-gcm_material\
401
+ ```
384
402
 
385
- 如果你在 PowerShell 看到提示是 `PS C:\Users\你>`,那就是 `C:\Users\你\ntu-cool-gcm_material\`。
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
- # Real extension for this file: prefer Canvas's display_name in
334
- # content_details, fall back to the title's suffix.
335
- content_details = item.get("content_details") or {}
336
- display_name = str(content_details.get("display_name") or "")
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
- stem = title[:-4] if title.lower().endswith(".pdf") else title
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
- per_type["pdf"].append(f"- 📄 [{title}]({week.label}/{urllib.parse.quote(fname)})")
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
- if yt_cookies is None or not yt_cookies.exists():
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" 注意: 找不到 YouTube cookies ({yt_cookies}),不公開影片可能下載失敗",
972
- f" WARNING: youtube cookies not found at {yt_cookies} unlisted videos may fail",
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=yt_cookies or Path(".secrets/youtube_cookies.txt"), yt_dlp=yt_dlp
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"
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()