@mo7yw4ng/openape 1.0.2 → 1.0.3

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 (41) hide show
  1. package/README.md +8 -5
  2. package/esm/deno.js +1 -1
  3. package/esm/src/commands/auth.d.ts +1 -1
  4. package/esm/src/commands/auth.d.ts.map +1 -1
  5. package/esm/src/commands/auth.js +3 -7
  6. package/esm/src/commands/forums.d.ts.map +1 -1
  7. package/esm/src/commands/forums.js +11 -16
  8. package/esm/src/commands/materials.d.ts.map +1 -1
  9. package/esm/src/commands/materials.js +171 -11
  10. package/esm/src/commands/videos.d.ts.map +1 -1
  11. package/esm/src/commands/videos.js +108 -75
  12. package/esm/src/index.js +2 -2
  13. package/esm/src/lib/moodle.d.ts +47 -1
  14. package/esm/src/lib/moodle.d.ts.map +1 -1
  15. package/esm/src/lib/moodle.js +169 -26
  16. package/esm/src/lib/types.d.ts +1 -0
  17. package/esm/src/lib/types.d.ts.map +1 -1
  18. package/esm/src/lib/utils.d.ts +20 -0
  19. package/esm/src/lib/utils.d.ts.map +1 -1
  20. package/esm/src/lib/utils.js +34 -3
  21. package/package.json +1 -2
  22. package/script/deno.js +1 -1
  23. package/script/src/commands/auth.d.ts +1 -1
  24. package/script/src/commands/auth.d.ts.map +1 -1
  25. package/script/src/commands/auth.js +4 -8
  26. package/script/src/commands/forums.d.ts.map +1 -1
  27. package/script/src/commands/forums.js +11 -16
  28. package/script/src/commands/materials.d.ts.map +1 -1
  29. package/script/src/commands/materials.js +173 -13
  30. package/script/src/commands/videos.d.ts.map +1 -1
  31. package/script/src/commands/videos.js +109 -76
  32. package/script/src/index.js +1 -1
  33. package/script/src/lib/moodle.d.ts +47 -1
  34. package/script/src/lib/moodle.d.ts.map +1 -1
  35. package/script/src/lib/moodle.js +172 -26
  36. package/script/src/lib/types.d.ts +1 -0
  37. package/script/src/lib/types.d.ts.map +1 -1
  38. package/script/src/lib/utils.d.ts +20 -0
  39. package/script/src/lib/utils.d.ts.map +1 -1
  40. package/script/src/lib/utils.js +37 -3
  41. package/skills/openape/SKILL.md +6 -26
package/README.md CHANGED
@@ -1,4 +1,7 @@
1
- # OpenApe CLI (Unofficial)
1
+ <h1 align="center">
2
+ <img src="assets/logo.svg" width="150" /><br/>
3
+ OpenApe CLI (Unofficial)
4
+ </h1>
2
5
 
3
6
  中原大學 [i-Learning](https://ilearning.cycu.edu.tw/) (Moodle) 平台自動化命令列工具 (CLI),幫助你快速查詢課程、觀看影片、下載教材。
4
7
 
@@ -6,7 +9,7 @@
6
9
  - 📚 **課程資訊**:列出修課清單、成績、課程大綱與進度。
7
10
  - 📺 **影片輔助**:列出/下載影片,甚至自動標記為已觀看。
8
11
  - 📝 **測驗與教材**:快速查看測驗、下載教材。
9
- - 💬 **討論區與公告**:在終端機直接閱讀公告與討論區。
12
+ - 💬 **討論區與公告**:閱讀公告與討論區。
10
13
  - 📅 **行事曆**:內建行事曆事件查詢與匯出。
11
14
  - 🤖 **Agent Skills**:提供 Claude Code 等 Skills 工作流支援。
12
15
 
@@ -33,8 +36,8 @@ deno install -A -g -n openape jsr:@openape/openape
33
36
  第一次使用需要登入,會開啟瀏覽器讓你手動完成登入,隨後會快取 Session 供未來使用。
34
37
  ```bash
35
38
  openape login # 登入並儲存 session (開啟瀏覽器)
36
- openape auth status # 檢查當前登入狀態
37
- openape auth logout # 登出並清除 session
39
+ openape status # 檢查當前登入狀態
40
+ openape logout # 登出並清除 session
38
41
  ```
39
42
 
40
43
  ### 課程 (Courses)
@@ -42,7 +45,7 @@ openape auth logout # 登出並清除 session
42
45
  openape courses list # 列出所有課程 (支援 --incomplete-only, --level)
43
46
  openape courses info <id> # 顯示特定課程的詳細資訊
44
47
  openape courses progress <id> # 顯示特定課程的進度
45
- openape courses syllabus <id> # 顯示課程大綱 (從 CMAP 取得)
48
+ openape courses syllabus <id> # 顯示課程大綱
46
49
  ```
47
50
 
48
51
  ### 影片 (Videos)
package/esm/deno.js CHANGED
@@ -1,6 +1,6 @@
1
1
  export default {
2
2
  "name": "@openape/openape",
3
- "version": "1.0.2",
3
+ "version": "1.0.3",
4
4
  "description": "CLI tool for CYCU i-Learning platform",
5
5
  "license": "MIT",
6
6
  "exports": "./src/index.ts",
@@ -1,3 +1,3 @@
1
1
  import { Command } from "commander";
2
- export declare function registerAuthCommand(program: Command): void;
2
+ export declare function registerCommand(program: Command): void;
3
3
  //# sourceMappingURL=auth.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../../../src/src/commands/auth.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAUpC,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CAyQ1D"}
1
+ {"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../../../src/src/commands/auth.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAUpC,wBAAgB,eAAe,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CAoQtD"}
@@ -5,10 +5,7 @@ import { findEdgePath } from "../lib/auth.js";
5
5
  import { saveSesskey, acquireWsToken, saveWsToken } from "../lib/token.js";
6
6
  import path from "node:path";
7
7
  import fs from "node:fs";
8
- export function registerAuthCommand(program) {
9
- const authCmd = program.command("auth");
10
- authCmd.description("Authentication commands");
11
- // Register login directly on program (not under auth subcommand)
8
+ export function registerCommand(program) {
12
9
  program
13
10
  .command("login")
14
11
  .description("Login to iLearning manually and save session")
@@ -186,8 +183,7 @@ export function registerAuthCommand(program) {
186
183
  await new Promise(resolve => setTimeout(resolve, 500));
187
184
  }
188
185
  });
189
- // auth subcommand group (for status and other auth-related commands)
190
- authCmd
186
+ program
191
187
  .command("status")
192
188
  .description("Check session status")
193
189
  .option("--session <path>", "Session file path", ".auth/storage-state.json")
@@ -236,7 +232,7 @@ export function registerAuthCommand(program) {
236
232
  console.log(JSON.stringify(result, null, 2));
237
233
  }
238
234
  });
239
- authCmd
235
+ program
240
236
  .command("logout")
241
237
  .description("Remove saved session")
242
238
  .option("--session <path>", "Session file path", ".auth/storage-state.json")
@@ -1 +1 @@
1
- {"version":3,"file":"forums.d.ts","sourceRoot":"","sources":["../../../src/src/commands/forums.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAiBpC,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CA0Q5D"}
1
+ {"version":3,"file":"forums.d.ts","sourceRoot":"","sources":["../../../src/src/commands/forums.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAkBpC,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CA6P5D"}
@@ -68,6 +68,7 @@ export function registerForumsCommand(program) {
68
68
  cmid: wsForum.cmid.toString(),
69
69
  forum_id: wsForum.id,
70
70
  name: wsForum.name,
71
+ timemodified: wsForum.timemodified,
71
72
  // url: `https://ilearning.cycu.edu.tw/mod/forum/view.php?id=${wsForum.cmid}`,
72
73
  });
73
74
  }
@@ -111,7 +112,7 @@ export function registerForumsCommand(program) {
111
112
  cmid: wsForum.cmid.toString(),
112
113
  forum_id: wsForum.id,
113
114
  name: wsForum.name,
114
- url: `https://ilearning.cycu.edu.tw/mod/forum/view.php?id=${wsForum.cmid}`,
115
+ timemodified: wsForum.timemodified,
115
116
  });
116
117
  }
117
118
  }
@@ -131,7 +132,6 @@ export function registerForumsCommand(program) {
131
132
  .description("List discussions in a forum (use forum ID)")
132
133
  .argument("<forum-id>", "Forum ID")
133
134
  .option("--output <format>", "Output format: json|csv|table|silent")
134
- .option("--msg", "Include message content in response")
135
135
  .action(async (forumId, options, command) => {
136
136
  const apiContext = await createApiContext(options, command);
137
137
  if (!apiContext) {
@@ -162,20 +162,15 @@ export function registerForumsCommand(program) {
162
162
  forum_name: targetForum.name,
163
163
  course_id: course?.id,
164
164
  course_name: course?.fullname,
165
- discussions: discussions.map(d => {
166
- const discussion = {
167
- id: d.id,
168
- name: d.name,
169
- user_id: d.userId,
170
- time_modified: d.timeModified,
171
- post_count: d.postCount,
172
- unread: d.unread,
173
- };
174
- if (options.msg) {
175
- discussion.message = stripHtmlTags(d.message || "");
176
- }
177
- return discussion;
178
- }),
165
+ discussions: discussions.map(d => ({
166
+ id: d.id,
167
+ name: d.name,
168
+ user_id: d.userId,
169
+ time_modified: d.timeModified,
170
+ post_count: d.postCount,
171
+ unread: d.unread,
172
+ message: (stripHtmlTags(d.message || "")).substring(0, 250) + "...",
173
+ })),
179
174
  summary: {
180
175
  total_discussions: discussions.length,
181
176
  },
@@ -1 +1 @@
1
- {"version":3,"file":"materials.d.ts","sourceRoot":"","sources":["../../../src/src/commands/materials.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AA+BpC,wBAAgB,wBAAwB,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CAka/D"}
1
+ {"version":3,"file":"materials.d.ts","sourceRoot":"","sources":["../../../src/src/commands/materials.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAgCpC,wBAAgB,wBAAwB,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CAqlB/D"}
@@ -1,20 +1,16 @@
1
- import { getBaseDir } from "../lib/utils.js";
2
- import { getEnrolledCourses, getEnrolledCoursesApi, getResourcesByCoursesApi } from "../lib/moodle.js";
1
+ import { getBaseDir, getOutputFormat, sanitizeFilename } from "../lib/utils.js";
2
+ import { getEnrolledCourses, getEnrolledCoursesApi, getResourcesByCoursesApi, updateActivityCompletionStatusManually, getSiteInfoApi, moodleApiCall } from "../lib/moodle.js";
3
3
  import { createLogger } from "../lib/logger.js";
4
4
  import { launchAuthenticated } from "../lib/auth.js";
5
5
  import { extractSessionInfo } from "../lib/session.js";
6
6
  import { closeBrowserSafely } from "../lib/auth.js";
7
+ import { formatAndOutput } from "../index.js";
7
8
  import { loadWsToken } from "../lib/token.js";
8
9
  import path from "node:path";
9
10
  import fs from "node:fs";
10
11
  export function registerMaterialsCommand(program) {
11
12
  const materialsCmd = program.command("materials");
12
13
  materialsCmd.description("Material/resource operations");
13
- // Helper to get output format from global or local options
14
- function getOutputFormat(command) {
15
- const opts = command.optsWithGlobals();
16
- return opts.output || "json";
17
- }
18
14
  // Pure API context - no browser required (fast!)
19
15
  async function createApiContext(options, command) {
20
16
  const opts = command?.optsWithGlobals ? command.optsWithGlobals() : options;
@@ -77,10 +73,6 @@ export function registerMaterialsCommand(program) {
77
73
  throw err;
78
74
  }
79
75
  }
80
- // Helper to sanitize filenames
81
- function sanitizeFilename(name) {
82
- return name.replace(/[<>:"/\\|?*]/g, "_").replace(/\s+/g, "_");
83
- }
84
76
  // Helper to download a single resource
85
77
  async function downloadResource(page, resource, outputDir, log) {
86
78
  try {
@@ -359,4 +351,172 @@ export function registerMaterialsCommand(program) {
359
351
  await closeBrowserSafely(browser, browserContext);
360
352
  }
361
353
  });
354
+ materialsCmd
355
+ .command("complete")
356
+ .description("Mark all incomplete resources (non-video) as complete in a course")
357
+ .argument("<course-id>", "Course ID")
358
+ .option("--dry-run", "Show what would be marked complete without doing it")
359
+ .option("--output <format>", "Output format: json|csv|table|silent")
360
+ .action(async (courseId, options, command) => {
361
+ const output = getOutputFormat(command);
362
+ const apiContext = await createApiContext(options, command);
363
+ if (!apiContext) {
364
+ process.exitCode = 1;
365
+ return;
366
+ }
367
+ try {
368
+ const { log, session } = apiContext;
369
+ // Get user ID
370
+ const siteInfo = await getSiteInfoApi(session);
371
+ // Get completion status for all activities in the course
372
+ const completionData = await moodleApiCall(session, "core_completion_get_activities_completion_status", { courseid: parseInt(courseId, 10), userid: siteInfo.userid });
373
+ if (!completionData?.statuses) {
374
+ log.info("No activities found in this course.");
375
+ return;
376
+ }
377
+ // Filter for resources (non-video) that have completion enabled but are not complete
378
+ const incompleteResources = completionData.statuses.filter((status) => {
379
+ // Only resources, not supervideo
380
+ if (status.modname === "supervideo")
381
+ return false;
382
+ // Must have completion enabled
383
+ if (!status.hascompletion)
384
+ return false;
385
+ // Must be incomplete
386
+ if (status.isoverallcomplete)
387
+ return false;
388
+ return true;
389
+ });
390
+ if (incompleteResources.length === 0) {
391
+ log.info("All resources are already complete (or no resources with completion tracking).");
392
+ return;
393
+ }
394
+ log.info(`Found ${incompleteResources.length} incomplete resources to complete:`);
395
+ for (const resource of incompleteResources) {
396
+ log.info(` - ${resource.name} (cmid: ${resource.cmid})`);
397
+ }
398
+ if (options.dryRun) {
399
+ log.info("\n[Dry run] No changes made.");
400
+ return;
401
+ }
402
+ // Mark each resource as complete
403
+ const results = [];
404
+ for (const resource of incompleteResources) {
405
+ const success = await updateActivityCompletionStatusManually(session, resource.cmid, true);
406
+ results.push({
407
+ cmid: resource.cmid,
408
+ name: resource.name,
409
+ success,
410
+ });
411
+ if (success) {
412
+ log.success(` ✓ Completed: ${resource.name}`);
413
+ }
414
+ else {
415
+ log.error(` ✗ Failed: ${resource.name}`);
416
+ }
417
+ }
418
+ const completed = results.filter(r => r.success).length;
419
+ const failed = results.filter(r => !r.success).length;
420
+ log.info(`\n執行結果: ${completed} 成功, ${failed} 失敗`);
421
+ if (output !== "silent") {
422
+ formatAndOutput(results, output, log);
423
+ }
424
+ }
425
+ catch (e) {
426
+ apiContext.log.error(`Error: ${e instanceof Error ? e.message : String(e)}`);
427
+ process.exitCode = 1;
428
+ }
429
+ });
430
+ materialsCmd
431
+ .command("complete-all")
432
+ .description("Mark all incomplete resources (non-video) as complete across all in-progress courses")
433
+ .option("--dry-run", "Show what would be marked complete without doing it")
434
+ .option("--level <type>", "Course level: in_progress (default) | all", "in_progress")
435
+ .option("--output <format>", "Output format: json|csv|table|silent")
436
+ .action(async (options, command) => {
437
+ const output = getOutputFormat(command);
438
+ const apiContext = await createApiContext(options, command);
439
+ if (!apiContext) {
440
+ process.exitCode = 1;
441
+ return;
442
+ }
443
+ try {
444
+ const { log, session } = apiContext;
445
+ // Get user ID
446
+ const siteInfo = await getSiteInfoApi(session);
447
+ // Get all courses
448
+ const classification = options.level === "all" ? undefined : "inprogress";
449
+ const courses = await getEnrolledCoursesApi(session, { classification });
450
+ log.info(`Scanning ${courses.length} courses for incomplete resources...`);
451
+ const allResults = [];
452
+ let totalIncomplete = 0;
453
+ for (const course of courses) {
454
+ try {
455
+ // Get completion status for all activities in the course
456
+ const completionData = await moodleApiCall(session, "core_completion_get_activities_completion_status", { courseid: course.id, userid: siteInfo.userid });
457
+ if (!completionData?.statuses)
458
+ continue;
459
+ // Filter for resources (non-video) that have completion enabled but are not complete
460
+ const incompleteResources = completionData.statuses.filter((status) => {
461
+ if (status.modname === "supervideo")
462
+ return false;
463
+ if (!status.hascompletion)
464
+ return false;
465
+ if (status.isoverallcomplete)
466
+ return false;
467
+ return true;
468
+ });
469
+ if (incompleteResources.length > 0) {
470
+ log.info(`\n${course.fullname}: ${incompleteResources.length} incomplete resources`);
471
+ totalIncomplete += incompleteResources.length;
472
+ if (options.dryRun) {
473
+ for (const resource of incompleteResources) {
474
+ log.info(` - ${resource.name} (cmid: ${resource.cmid})`);
475
+ }
476
+ }
477
+ else {
478
+ for (const resource of incompleteResources) {
479
+ const success = await updateActivityCompletionStatusManually(session, resource.cmid, true);
480
+ allResults.push({
481
+ courseId: course.id,
482
+ courseName: course.fullname,
483
+ cmid: resource.cmid,
484
+ name: resource.name,
485
+ success,
486
+ });
487
+ if (success) {
488
+ log.success(` ✓ Completed: ${resource.name}`);
489
+ }
490
+ else {
491
+ log.error(` ✗ Failed: ${resource.name}`);
492
+ }
493
+ }
494
+ }
495
+ }
496
+ }
497
+ catch (e) {
498
+ log.warn(`Failed to process course ${course.fullname}: ${e}`);
499
+ }
500
+ }
501
+ if (totalIncomplete === 0) {
502
+ log.info("\nAll resources are already complete (or no resources with completion tracking).");
503
+ return;
504
+ }
505
+ if (options.dryRun) {
506
+ log.info(`\n[Dry run] Found ${totalIncomplete} incomplete resources across ${courses.length} courses.`);
507
+ log.info("Run without --dry-run to mark them as complete.");
508
+ return;
509
+ }
510
+ const completed = allResults.filter(r => r.success).length;
511
+ const failed = allResults.filter(r => !r.success).length;
512
+ log.info(`\n執行結果: ${completed} 成功, ${failed} 失敗`);
513
+ if (output !== "silent") {
514
+ formatAndOutput(allResults, output, log);
515
+ }
516
+ }
517
+ catch (e) {
518
+ apiContext.log.error(`Error: ${e instanceof Error ? e.message : String(e)}`);
519
+ process.exitCode = 1;
520
+ }
521
+ });
362
522
  }
@@ -1 +1 @@
1
- {"version":3,"file":"videos.d.ts","sourceRoot":"","sources":["../../../src/src/commands/videos.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAYpC,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CAuV5D"}
1
+ {"version":3,"file":"videos.d.ts","sourceRoot":"","sources":["../../../src/src/commands/videos.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAYpC,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CA+X5D"}
@@ -1,5 +1,5 @@
1
- import { getBaseDir } from "../lib/utils.js";
2
- import { getEnrolledCourses, getSupervideosInCourse, getSupervideosInCourseApi, getVideoMetadata, completeVideo, downloadVideo } from "../lib/moodle.js";
1
+ import { getBaseDir, getOutputFormat, sanitizeFilename } from "../lib/utils.js";
2
+ import { getEnrolledCoursesApi, getSupervideosInCourse, getSupervideosInCourseApi, getVideoMetadata, completeVideoApi, downloadVideo, getIncompleteVideosApi } from "../lib/moodle.js";
3
3
  import { createLogger } from "../lib/logger.js";
4
4
  import { launchAuthenticated } from "../lib/auth.js";
5
5
  import { extractSessionInfo } from "../lib/session.js";
@@ -11,15 +11,11 @@ import fs from "node:fs";
11
11
  export function registerVideosCommand(program) {
12
12
  const videosCmd = program.command("videos");
13
13
  videosCmd.description("Video progress operations");
14
- // Helper to get output format from global or local options
15
- function getOutputFormat(command) {
16
- const opts = command.optsWithGlobals();
17
- return opts.output || "json";
18
- }
19
14
  // Pure API context - no browser required (fast!)
20
15
  async function createApiContext(options, command) {
21
16
  const opts = command?.optsWithGlobals ? command.optsWithGlobals() : options;
22
- const outputFormat = getOutputFormat(command || { optsWithGlobals: () => ({ output: "json" }) });
17
+ // Don't silence logs for commands that don't have explicit output format control
18
+ const outputFormat = command && command.optsWithGlobals ? getOutputFormat(command) : "table";
23
19
  const silent = outputFormat === "json" && !opts.verbose;
24
20
  const log = createLogger(opts.verbose, silent);
25
21
  const baseDir = getBaseDir();
@@ -91,46 +87,61 @@ export function registerVideosCommand(program) {
91
87
  process.exitCode = 1;
92
88
  return;
93
89
  }
94
- const videos = await getSupervideosInCourseApi(apiContext.session, parseInt(courseId, 10));
95
- // Note: API doesn't provide completion status, so --incomplete-only shows all
90
+ let videos = await getSupervideosInCourseApi(apiContext.session, parseInt(courseId, 10));
91
+ // Filter for incomplete videos if requested
96
92
  if (options.incompleteOnly) {
97
- apiContext.log.warn("--incomplete-only is not supported in API mode, showing all videos");
93
+ videos = videos.filter(v => !v.isComplete);
98
94
  }
99
95
  formatAndOutput(videos, output, apiContext.log);
100
96
  });
101
97
  videosCmd
102
98
  .command("complete")
103
- .description("Complete videos in a course (requires browser)")
99
+ .description("Complete videos in a course (uses API for list & completion, browser for metadata)")
104
100
  .argument("<course-id>", "Course ID")
105
101
  .option("--dry-run", "Discover videos but don't complete them")
106
102
  .option("--output <format>", "Output format: json|csv|table|silent")
107
103
  .action(async (courseId, options, command) => {
104
+ const output = getOutputFormat(command);
105
+ // Get API context for getting incomplete videos and completion
106
+ const apiContext = await createApiContext(options, command);
107
+ if (!apiContext) {
108
+ process.exitCode = 1;
109
+ return;
110
+ }
111
+ // Get incomplete videos via API (fast, no browser needed)
112
+ const incompleteVideos = await getIncompleteVideosApi(apiContext.session, parseInt(courseId, 10));
113
+ if (incompleteVideos.length === 0) {
114
+ apiContext.log.info("所有影片已完成(或無影片)。");
115
+ return;
116
+ }
117
+ apiContext.log.info(`找到 ${incompleteVideos.length} 部未完成影片`);
118
+ // Dry-run: show videos without needing browser
119
+ if (options.dryRun) {
120
+ const results = incompleteVideos.map(v => ({ name: v.name, success: true }));
121
+ for (const video of incompleteVideos) {
122
+ apiContext.log.info(` [試執行] ${video.name}`);
123
+ }
124
+ apiContext.log.info(`\n執行結果: ${results.length} 影片將被完成`);
125
+ if (output !== "silent") {
126
+ formatAndOutput(results, output, apiContext.log);
127
+ }
128
+ return;
129
+ }
130
+ // Need browser only for getting viewId and duration (not needed for dry-run)
108
131
  const context = await createSessionContext(options, command);
109
132
  if (!context) {
110
133
  process.exitCode = 1;
111
134
  return;
112
135
  }
113
- const { log, page, session, browser, context: browserContext } = context;
114
- const output = getOutputFormat(command);
136
+ const { log, page, browser, context: browserContext } = context;
115
137
  try {
116
- const videos = await getSupervideosInCourse(page, session, parseInt(courseId, 10), log, {
117
- incompleteOnly: true, // Only operate on incomplete videos
118
- });
119
- if (videos.length === 0) {
120
- log.info("所有影片已完成(或無影片)。");
121
- return;
122
- }
123
138
  const results = [];
124
- for (const sv of videos) {
139
+ for (const sv of incompleteVideos) {
125
140
  log.info(`處理中: ${sv.name}`);
126
141
  try {
127
142
  const video = await getVideoMetadata(page, sv.url, log);
128
- if (options.dryRun) {
129
- log.info(` [試執行] viewId=${video.viewId}, duration=${video.duration}s`);
130
- results.push({ name: sv.name, success: true });
131
- continue;
132
- }
133
- const success = await completeVideo(page, session, { ...video, cmid: sv.cmid }, log);
143
+ // Use WS API for completion
144
+ const success = await completeVideoApi(apiContext.session, { ...video, cmid: sv.cmid.toString() });
134
145
  if (success) {
135
146
  log.success(` 已完成!`);
136
147
  results.push({ name: sv.name, success: true });
@@ -159,65 +170,94 @@ export function registerVideosCommand(program) {
159
170
  });
160
171
  videosCmd
161
172
  .command("complete-all")
162
- .description("Complete all incomplete videos across all courses (requires browser)")
173
+ .description("Complete all incomplete videos across all courses (uses API for list & completion, browser for metadata)")
163
174
  .option("--dry-run", "Discover videos but don't complete them")
164
175
  .option("--output <format>", "Output format: json|csv|table|silent")
165
176
  .action(async (options, command) => {
177
+ const output = getOutputFormat(command);
178
+ // Get API context for getting incomplete videos and completion
179
+ const apiContext = await createApiContext(options, command);
180
+ if (!apiContext) {
181
+ process.exitCode = 1;
182
+ return;
183
+ }
184
+ // Get all courses via API
185
+ const classification = undefined; // all courses
186
+ const courses = await getEnrolledCoursesApi(apiContext.session, { classification });
187
+ apiContext.log.info(`掃描 ${courses.length} 個課程...`);
188
+ // Collect all incomplete videos across all courses using flatMap for cleaner code
189
+ const allIncompleteVideos = (await Promise.allSettled(courses.map(async (course) => {
190
+ try {
191
+ const videos = await getIncompleteVideosApi(apiContext.session, course.id);
192
+ return videos.map((video) => ({
193
+ courseId: course.id,
194
+ courseName: course.fullname,
195
+ cmid: video.cmid,
196
+ name: video.name,
197
+ url: video.url,
198
+ }));
199
+ }
200
+ catch (e) {
201
+ apiContext.log.warn(`無法取得課程 ${course.fullname} 的影片: ${e}`);
202
+ return [];
203
+ }
204
+ })))
205
+ .filter((result) => result.status === "fulfilled")
206
+ .flatMap((result) => result.status === "fulfilled" ? result.value : []);
207
+ if (allIncompleteVideos.length === 0) {
208
+ apiContext.log.info("所有影片已完成(或無影片)。");
209
+ return;
210
+ }
211
+ apiContext.log.info(`找到 ${allIncompleteVideos.length} 部未完成影片`);
212
+ // Dry-run: show videos without needing browser
213
+ if (options.dryRun) {
214
+ for (const video of allIncompleteVideos) {
215
+ apiContext.log.info(` [試執行] [${video.courseName}] ${video.name}`);
216
+ }
217
+ apiContext.log.info("\n===== 執行結果 =====");
218
+ apiContext.log.info(`掃描課程數: ${courses.length}`);
219
+ apiContext.log.info(`找到未完成影片: ${allIncompleteVideos.length}`);
220
+ apiContext.log.info(`執行影片數: ${allIncompleteVideos.length} (試執行)`);
221
+ return;
222
+ }
223
+ // Need browser only for getting viewId and duration (not needed for dry-run)
166
224
  const context = await createSessionContext(options, command);
167
225
  if (!context) {
168
226
  process.exitCode = 1;
169
227
  return;
170
228
  }
171
- const { log, page, session, browser, context: browserContext } = context;
172
- const output = getOutputFormat(command);
229
+ const { log, page, browser, context: browserContext } = context;
173
230
  try {
174
- const courses = await getEnrolledCourses(page, session, log);
175
231
  const allResults = [];
176
- let totalVideos = 0;
177
232
  let totalCompleted = 0;
178
233
  let totalFailed = 0;
179
- for (const course of courses) {
180
- log.info(`\n======================================`);
181
- log.info(`課程: ${course.fullname}`);
182
- log.info(`======================================`);
183
- const videos = await getSupervideosInCourse(page, session, course.id, log);
184
- if (videos.length === 0) {
185
- log.info(" 所有影片已完成(或無影片)。");
186
- continue;
187
- }
188
- totalVideos += videos.length;
189
- for (const sv of videos) {
190
- log.info(` 處理中: ${sv.name}`);
191
- try {
192
- const video = await getVideoMetadata(page, sv.url, log);
193
- if (options.dryRun) {
194
- log.info(` [試執行] viewId=${video.viewId}, duration=${video.duration}s`);
195
- allResults.push({ courseName: course.fullname, name: sv.name, success: true });
196
- continue;
197
- }
198
- const success = await completeVideo(page, session, { ...video, cmid: sv.cmid }, log);
199
- if (success) {
200
- log.success(` 已完成!`);
201
- allResults.push({ courseName: course.fullname, name: sv.name, success: true });
202
- totalCompleted++;
203
- }
204
- else {
205
- log.error(` 失敗。`);
206
- allResults.push({ courseName: course.fullname, name: sv.name, success: false, error: "Failed to complete" });
207
- totalFailed++;
208
- }
234
+ for (const video of allIncompleteVideos) {
235
+ log.info(`處理中: [${video.courseName}] ${video.name}`);
236
+ try {
237
+ const metadata = await getVideoMetadata(page, video.url, log);
238
+ // Use WS API for completion
239
+ const success = await completeVideoApi(apiContext.session, { ...metadata, cmid: video.cmid.toString() });
240
+ if (success) {
241
+ log.success(` 已完成!`);
242
+ allResults.push({ courseName: video.courseName, name: video.name, success: true });
243
+ totalCompleted++;
209
244
  }
210
- catch (err) {
211
- const msg = err instanceof Error ? err.message : String(err);
212
- log.error(` 錯誤: ${msg}`);
213
- allResults.push({ courseName: course.fullname, name: sv.name, success: false, error: msg });
245
+ else {
246
+ log.error(` 失敗。`);
247
+ allResults.push({ courseName: video.courseName, name: video.name, success: false, error: "Failed to complete" });
214
248
  totalFailed++;
215
249
  }
216
250
  }
251
+ catch (err) {
252
+ const msg = err instanceof Error ? err.message : String(err);
253
+ log.error(` 錯誤: ${msg}`);
254
+ allResults.push({ courseName: video.courseName, name: video.name, success: false, error: msg });
255
+ totalFailed++;
256
+ }
217
257
  }
218
258
  log.info("\n===== 執行結果 =====");
219
259
  log.info(`掃描課程數: ${courses.length}`);
220
- log.info(`掃描影片數: ${totalVideos}`);
260
+ log.info(`找到未完成影片: ${allIncompleteVideos.length}`);
221
261
  log.info(`執行影片數: ${totalCompleted}`);
222
262
  if (totalFailed > 0)
223
263
  log.warn(`失敗影片數: ${totalFailed}`);
@@ -229,13 +269,6 @@ export function registerVideosCommand(program) {
229
269
  await closeBrowserSafely(browser, browserContext);
230
270
  }
231
271
  });
232
- // Helper function to sanitize filename
233
- function sanitizeFilename(name) {
234
- return name
235
- .replace(/[<>:"/\\|?*]/g, "_")
236
- .replace(/\s+/g, "_")
237
- .substring(0, 200);
238
- }
239
272
  videosCmd
240
273
  .command("download")
241
274
  .description("Download videos from a course (requires browser)")
package/esm/src/index.js CHANGED
@@ -11,7 +11,7 @@ import denoJson from "../deno.js";
11
11
  import { registerCoursesCommand } from "./commands/courses.js";
12
12
  import { registerVideosCommand } from "./commands/videos.js";
13
13
  import { registerQuizzesCommand } from "./commands/quizzes.js";
14
- import { registerAuthCommand } from "./commands/auth.js";
14
+ import { registerCommand } from "./commands/auth.js";
15
15
  import { registerMaterialsCommand } from "./commands/materials.js";
16
16
  import { registerGradesCommand } from "./commands/grades.js";
17
17
  import { registerForumsCommand } from "./commands/forums.js";
@@ -32,7 +32,7 @@ program
32
32
  .option("--silent", "Suppress all log output (JSON only)")
33
33
  .option("--headed", "Run browser in visible mode");
34
34
  // Register subcommands
35
- registerAuthCommand(program);
35
+ registerCommand(program);
36
36
  registerCoursesCommand(program);
37
37
  registerVideosCommand(program);
38
38
  registerQuizzesCommand(program);