@mo7yw4ng/openape 1.0.0 → 1.0.2

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 (133) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +15 -5
  3. package/esm/_dnt.polyfills.d.ts +83 -6
  4. package/esm/_dnt.polyfills.d.ts.map +1 -0
  5. package/esm/_dnt.polyfills.js +127 -1
  6. package/esm/_dnt.shims.d.ts +1 -0
  7. package/esm/_dnt.shims.d.ts.map +1 -0
  8. package/esm/deno.d.ts +2 -0
  9. package/esm/deno.d.ts.map +1 -0
  10. package/esm/deno.js +2 -1
  11. package/esm/src/commands/announcements.d.ts +3 -0
  12. package/esm/src/commands/announcements.d.ts.map +1 -0
  13. package/esm/src/commands/announcements.js +135 -0
  14. package/esm/src/commands/auth.d.ts +3 -0
  15. package/esm/src/commands/auth.d.ts.map +1 -0
  16. package/esm/src/commands/auth.js +264 -0
  17. package/esm/src/commands/calendar.d.ts +3 -0
  18. package/esm/src/commands/calendar.d.ts.map +1 -0
  19. package/esm/src/commands/calendar.js +180 -0
  20. package/esm/src/commands/courses.d.ts +3 -0
  21. package/esm/src/commands/courses.d.ts.map +1 -0
  22. package/esm/src/commands/courses.js +348 -0
  23. package/esm/src/commands/forums.d.ts +3 -0
  24. package/esm/src/commands/forums.d.ts.map +1 -0
  25. package/esm/src/commands/forums.js +231 -0
  26. package/esm/src/commands/grades.d.ts +3 -0
  27. package/esm/src/commands/grades.d.ts.map +1 -0
  28. package/esm/src/commands/grades.js +121 -0
  29. package/esm/src/commands/materials.d.ts +3 -0
  30. package/esm/src/commands/materials.d.ts.map +1 -0
  31. package/esm/src/commands/materials.js +362 -0
  32. package/esm/src/commands/quizzes.d.ts +3 -0
  33. package/esm/src/commands/quizzes.d.ts.map +1 -0
  34. package/esm/src/commands/quizzes.js +160 -0
  35. package/esm/src/commands/skills.d.ts +3 -0
  36. package/esm/src/commands/skills.d.ts.map +1 -0
  37. package/esm/src/commands/skills.js +110 -0
  38. package/esm/src/commands/videos.d.ts +3 -0
  39. package/esm/src/commands/videos.d.ts.map +1 -0
  40. package/esm/src/commands/videos.js +302 -0
  41. package/esm/src/index.d.ts +27 -0
  42. package/esm/src/index.d.ts.map +1 -0
  43. package/esm/src/index.js +149 -0
  44. package/esm/src/lib/auth.d.ts +25 -0
  45. package/esm/src/lib/auth.d.ts.map +1 -0
  46. package/esm/src/lib/auth.js +194 -0
  47. package/esm/src/lib/config.d.ts +6 -0
  48. package/esm/src/lib/config.d.ts.map +1 -0
  49. package/esm/src/lib/config.js +36 -0
  50. package/esm/src/lib/logger.d.ts +3 -0
  51. package/esm/src/lib/logger.d.ts.map +1 -0
  52. package/esm/src/lib/logger.js +24 -0
  53. package/esm/src/lib/moodle.d.ts +205 -0
  54. package/esm/src/lib/moodle.d.ts.map +1 -0
  55. package/esm/src/lib/moodle.js +690 -0
  56. package/esm/src/lib/session.d.ts +8 -0
  57. package/esm/src/lib/session.d.ts.map +1 -0
  58. package/esm/src/lib/session.js +42 -0
  59. package/esm/src/lib/token.d.ts +38 -0
  60. package/esm/src/lib/token.d.ts.map +1 -0
  61. package/esm/src/lib/token.js +178 -0
  62. package/esm/src/lib/types.d.ts +271 -0
  63. package/esm/src/lib/types.d.ts.map +1 -0
  64. package/esm/src/lib/types.js +1 -0
  65. package/esm/src/lib/utils.d.ts +17 -0
  66. package/esm/src/lib/utils.d.ts.map +1 -0
  67. package/esm/src/lib/utils.js +51 -0
  68. package/package.json +7 -3
  69. package/script/_dnt.polyfills.d.ts +83 -6
  70. package/script/_dnt.polyfills.d.ts.map +1 -0
  71. package/script/_dnt.polyfills.js +128 -0
  72. package/script/_dnt.shims.d.ts +1 -0
  73. package/script/_dnt.shims.d.ts.map +1 -0
  74. package/script/deno.d.ts +2 -0
  75. package/script/deno.d.ts.map +1 -0
  76. package/script/deno.js +2 -1
  77. package/script/src/commands/announcements.d.ts +1 -0
  78. package/script/src/commands/announcements.d.ts.map +1 -0
  79. package/script/src/commands/announcements.js +75 -222
  80. package/script/src/commands/auth.d.ts +1 -0
  81. package/script/src/commands/auth.d.ts.map +1 -0
  82. package/script/src/commands/auth.js +49 -17
  83. package/script/src/commands/calendar.d.ts +1 -0
  84. package/script/src/commands/calendar.d.ts.map +1 -0
  85. package/script/src/commands/calendar.js +112 -301
  86. package/script/src/commands/courses.d.ts +1 -0
  87. package/script/src/commands/courses.d.ts.map +1 -0
  88. package/script/src/commands/courses.js +43 -173
  89. package/script/src/commands/forums.d.ts +1 -0
  90. package/script/src/commands/forums.d.ts.map +1 -0
  91. package/script/src/commands/forums.js +145 -311
  92. package/script/src/commands/grades.d.ts +1 -0
  93. package/script/src/commands/grades.d.ts.map +1 -0
  94. package/script/src/commands/grades.js +62 -194
  95. package/script/src/commands/materials.d.ts +1 -0
  96. package/script/src/commands/materials.d.ts.map +1 -0
  97. package/script/src/commands/materials.js +111 -166
  98. package/script/src/commands/quizzes.d.ts +1 -0
  99. package/script/src/commands/quizzes.d.ts.map +1 -0
  100. package/script/src/commands/quizzes.js +40 -102
  101. package/script/src/commands/skills.d.ts +1 -0
  102. package/script/src/commands/skills.d.ts.map +1 -0
  103. package/script/src/commands/skills.js +17 -18
  104. package/script/src/commands/videos.d.ts +1 -0
  105. package/script/src/commands/videos.d.ts.map +1 -0
  106. package/script/src/commands/videos.js +26 -52
  107. package/script/src/index.d.ts +1 -0
  108. package/script/src/index.d.ts.map +1 -0
  109. package/script/src/index.js +4 -4
  110. package/script/src/lib/auth.d.ts +1 -0
  111. package/script/src/lib/auth.d.ts.map +1 -0
  112. package/script/src/lib/auth.js +9 -10
  113. package/script/src/lib/config.d.ts +1 -0
  114. package/script/src/lib/config.d.ts.map +1 -0
  115. package/script/src/lib/config.js +6 -7
  116. package/script/src/lib/logger.d.ts +1 -0
  117. package/script/src/lib/logger.d.ts.map +1 -0
  118. package/script/src/lib/logger.js +1 -2
  119. package/script/src/lib/moodle.d.ts +25 -54
  120. package/script/src/lib/moodle.d.ts.map +1 -0
  121. package/script/src/lib/moodle.js +103 -324
  122. package/script/src/lib/session.d.ts +1 -0
  123. package/script/src/lib/session.d.ts.map +1 -0
  124. package/script/src/lib/session.js +3 -29
  125. package/script/src/lib/token.d.ts +16 -5
  126. package/script/src/lib/token.d.ts.map +1 -0
  127. package/script/src/lib/token.js +71 -36
  128. package/script/src/lib/types.d.ts +10 -0
  129. package/script/src/lib/types.d.ts.map +1 -0
  130. package/script/src/lib/utils.d.ts +12 -0
  131. package/script/src/lib/utils.d.ts.map +1 -0
  132. package/script/src/lib/utils.js +57 -11
  133. package/skills/openape/SKILL.md +331 -328
@@ -0,0 +1,121 @@
1
+ import { getBaseDir } from "../lib/utils.js";
2
+ import { getEnrolledCoursesApi, getCourseGradesApi } from "../lib/moodle.js";
3
+ import { createLogger } from "../lib/logger.js";
4
+ import { loadWsToken } from "../lib/token.js";
5
+ import { formatAndOutput } from "../index.js";
6
+ import path from "node:path";
7
+ import fs from "node:fs";
8
+ export function registerGradesCommand(program) {
9
+ const gradesCmd = program.command("grades");
10
+ gradesCmd.description("Grade operations");
11
+ function getOutputFormat(command) {
12
+ const opts = command.optsWithGlobals();
13
+ return opts.output || "json";
14
+ }
15
+ // Pure API context - no browser required (fast!)
16
+ async function createApiContext(options, command) {
17
+ const opts = command?.optsWithGlobals ? command.optsWithGlobals() : options;
18
+ const outputFormat = getOutputFormat(command || { optsWithGlobals: () => ({ output: "json" }) });
19
+ const silent = outputFormat === "json" && !opts.verbose;
20
+ const log = createLogger(opts.verbose, silent);
21
+ const baseDir = getBaseDir();
22
+ const sessionPath = path.resolve(baseDir, ".auth", "storage-state.json");
23
+ // Check if session exists
24
+ if (!fs.existsSync(sessionPath)) {
25
+ log.error("未找到登入 session。請先執行 'openape auth login' 進行登入。");
26
+ log.info(`Session 預期位置: ${sessionPath}`);
27
+ return null;
28
+ }
29
+ // Try to load WS token
30
+ const wsToken = loadWsToken(sessionPath);
31
+ if (!wsToken) {
32
+ log.error("未找到 WS token。請先執行 'openape auth login' 進行登入。");
33
+ return null;
34
+ }
35
+ return {
36
+ log,
37
+ session: {
38
+ wsToken,
39
+ moodleBaseUrl: "https://ilearning.cycu.edu.tw",
40
+ },
41
+ };
42
+ }
43
+ gradesCmd
44
+ .command("summary")
45
+ .description("Show grade summary across all courses")
46
+ .option("--output <format>", "Output format: json|csv|table|silent")
47
+ .action(async (options, command) => {
48
+ const output = getOutputFormat(command);
49
+ const apiContext = await createApiContext(options, command);
50
+ if (!apiContext) {
51
+ process.exitCode = 1;
52
+ return;
53
+ }
54
+ const courses = await getEnrolledCoursesApi(apiContext.session);
55
+ const gradeSummaries = [];
56
+ for (const course of courses) {
57
+ const grades = await getCourseGradesApi(apiContext.session, course.id);
58
+ gradeSummaries.push({
59
+ courseId: course.id,
60
+ courseName: course.fullname,
61
+ grade: grades.grade,
62
+ gradeFormatted: grades.gradeFormatted,
63
+ rank: grades.rank,
64
+ totalUsers: grades.totalUsers,
65
+ });
66
+ }
67
+ // Calculate overall statistics
68
+ const gradedCourses = gradeSummaries.filter(g => g.grade !== undefined && g.grade !== null && g.grade !== "-");
69
+ const averageRank = gradeSummaries
70
+ .filter(g => g.rank !== undefined && g.rank !== null)
71
+ .reduce((sum, g) => sum + (g.rank || 0), 0) /
72
+ (gradeSummaries.filter(g => g.rank !== undefined && g.rank !== null).length || 1);
73
+ const summaryData = {
74
+ total_courses: courses.length,
75
+ graded_courses: gradedCourses.length,
76
+ average_rank: averageRank.toFixed(1),
77
+ grades: gradeSummaries,
78
+ };
79
+ formatAndOutput(summaryData, output, apiContext.log);
80
+ });
81
+ gradesCmd
82
+ .command("course")
83
+ .description("Show detailed grades for a specific course")
84
+ .argument("<course-id>", "Course ID")
85
+ .option("--output <format>", "Output format: json|csv|table|silent")
86
+ .action(async (courseId, options, command) => {
87
+ const output = getOutputFormat(command);
88
+ const apiContext = await createApiContext(options, command);
89
+ if (!apiContext) {
90
+ process.exitCode = 1;
91
+ return;
92
+ }
93
+ const courses = await getEnrolledCoursesApi(apiContext.session);
94
+ const course = courses.find(c => c.id === parseInt(courseId, 10));
95
+ if (!course) {
96
+ apiContext.log.error(`Course not found: ${courseId}`);
97
+ process.exitCode = 1;
98
+ return;
99
+ }
100
+ const grades = await getCourseGradesApi(apiContext.session, course.id);
101
+ const gradeData = {
102
+ courseId: grades.courseId,
103
+ courseName: grades.courseName,
104
+ grade: grades.grade,
105
+ gradeFormatted: grades.gradeFormatted,
106
+ rank: grades.rank,
107
+ totalUsers: grades.totalUsers,
108
+ items: grades.items?.map(item => ({
109
+ name: item.name,
110
+ grade: item.grade,
111
+ gradeFormatted: item.gradeFormatted,
112
+ range: item.range,
113
+ percentage: item.percentage,
114
+ weight: item.weight,
115
+ feedback: item.feedback,
116
+ graded: item.graded,
117
+ })),
118
+ };
119
+ formatAndOutput(gradeData, output, apiContext.log);
120
+ });
121
+ }
@@ -0,0 +1,3 @@
1
+ import { Command } from "commander";
2
+ export declare function registerMaterialsCommand(program: Command): void;
3
+ //# sourceMappingURL=materials.d.ts.map
@@ -0,0 +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"}
@@ -0,0 +1,362 @@
1
+ import { getBaseDir } from "../lib/utils.js";
2
+ import { getEnrolledCourses, getEnrolledCoursesApi, getResourcesByCoursesApi } from "../lib/moodle.js";
3
+ import { createLogger } from "../lib/logger.js";
4
+ import { launchAuthenticated } from "../lib/auth.js";
5
+ import { extractSessionInfo } from "../lib/session.js";
6
+ import { closeBrowserSafely } from "../lib/auth.js";
7
+ import { loadWsToken } from "../lib/token.js";
8
+ import path from "node:path";
9
+ import fs from "node:fs";
10
+ export function registerMaterialsCommand(program) {
11
+ const materialsCmd = program.command("materials");
12
+ 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
+ // Pure API context - no browser required (fast!)
19
+ async function createApiContext(options, command) {
20
+ const opts = command?.optsWithGlobals ? command.optsWithGlobals() : options;
21
+ const outputFormat = getOutputFormat(command || { optsWithGlobals: () => ({ output: "json" }) });
22
+ const silent = outputFormat === "json" && !opts.verbose;
23
+ const log = createLogger(opts.verbose, silent);
24
+ const baseDir = getBaseDir();
25
+ const sessionPath = path.resolve(baseDir, ".auth", "storage-state.json");
26
+ // Check if session exists
27
+ if (!fs.existsSync(sessionPath)) {
28
+ log.error("未找到登入 session。請先執行 'openape auth login' 進行登入。");
29
+ log.info(`Session 預期位置: ${sessionPath}`);
30
+ return null;
31
+ }
32
+ // Try to load WS token
33
+ const wsToken = loadWsToken(sessionPath);
34
+ if (!wsToken) {
35
+ log.error("未找到 WS token。請先執行 'openape auth login' 進行登入。");
36
+ return null;
37
+ }
38
+ return {
39
+ log,
40
+ session: {
41
+ wsToken,
42
+ moodleBaseUrl: "https://ilearning.cycu.edu.tw",
43
+ },
44
+ };
45
+ }
46
+ // Helper function to create session context (for download commands)
47
+ async function createSessionContext(options, command) {
48
+ const opts = command?.optsWithGlobals ? command.optsWithGlobals() : options;
49
+ const outputFormat = getOutputFormat(command || { optsWithGlobals: () => ({ output: "json" }) });
50
+ const silent = outputFormat === "json" && !opts.verbose;
51
+ const log = createLogger(opts.verbose, silent);
52
+ const baseDir = getBaseDir();
53
+ const sessionPath = path.resolve(baseDir, ".auth", "storage-state.json");
54
+ if (!fs.existsSync(sessionPath)) {
55
+ log.error("未找到登入 session。請先執行 'openape auth login' 進行登入。");
56
+ return null;
57
+ }
58
+ const config = {
59
+ username: "",
60
+ password: "",
61
+ courseUrl: "",
62
+ moodleBaseUrl: "https://ilearning.cycu.edu.tw",
63
+ headless: !options.headed,
64
+ slowMo: 0,
65
+ authStatePath: sessionPath,
66
+ ollamaBaseUrl: "",
67
+ };
68
+ log.info("啟動瀏覽器...");
69
+ const { browser, context, page } = await launchAuthenticated(config, log);
70
+ try {
71
+ const session = await extractSessionInfo(page, config, log);
72
+ return { log, page, session, browser, context };
73
+ }
74
+ catch (err) {
75
+ await context.close();
76
+ await browser.close();
77
+ throw err;
78
+ }
79
+ }
80
+ // Helper to sanitize filenames
81
+ function sanitizeFilename(name) {
82
+ return name.replace(/[<>:"/\\|?*]/g, "_").replace(/\s+/g, "_");
83
+ }
84
+ // Helper to download a single resource
85
+ async function downloadResource(page, resource, outputDir, log) {
86
+ try {
87
+ // Only download resource type (skip url)
88
+ if (resource.modType !== "resource") {
89
+ log.debug(` Skipping ${resource.modType}: ${resource.name}`);
90
+ return null;
91
+ }
92
+ // Create course directory
93
+ const courseDir = path.join(outputDir, sanitizeFilename(resource.course_name));
94
+ if (!fs.existsSync(courseDir)) {
95
+ fs.mkdirSync(courseDir, { recursive: true });
96
+ }
97
+ // Navigate to resource page
98
+ log.debug(` Downloading: ${resource.name}`);
99
+ await page.goto(resource.url, { waitUntil: "domcontentloaded", timeout: 30000 });
100
+ // Try to find download link on the page
101
+ const downloadLinks = await page.$$eval('a[href*="forcedownload=1"]', (links) => links.map((a) => a.href));
102
+ if (downloadLinks.length === 0) {
103
+ log.warn(` No download link found for: ${resource.name}`);
104
+ return null;
105
+ }
106
+ // Download the first available file
107
+ const downloadUrl = downloadLinks[0];
108
+ // Extract filename from URL or use resource name
109
+ const urlObj = new URL(downloadUrl);
110
+ const filenameParam = urlObj.searchParams.get("filename");
111
+ let filename = filenameParam || sanitizeFilename(resource.name);
112
+ // Add extension if missing
113
+ if (resource.mimetype && !path.extname(filename)) {
114
+ const extMap = {
115
+ "application/pdf": ".pdf",
116
+ "application/vnd.ms-powerpoint": ".ppt",
117
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation": ".pptx",
118
+ "application/msword": ".doc",
119
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx",
120
+ "application/vnd.ms-excel": ".xls",
121
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx",
122
+ "application/zip": ".zip",
123
+ "image/jpeg": ".jpg",
124
+ "image/png": ".png",
125
+ };
126
+ if (extMap[resource.mimetype]) {
127
+ filename += extMap[resource.mimetype];
128
+ }
129
+ }
130
+ const outputPath = path.join(courseDir, filename);
131
+ // Trigger download
132
+ const downloadPromise = page.waitForEvent("download", { timeout: 30000 });
133
+ await page.goto(downloadUrl, { waitUntil: "domcontentloaded" });
134
+ const download = await downloadPromise;
135
+ // Save file
136
+ await download.saveAs(outputPath);
137
+ const stats = fs.statSync(outputPath);
138
+ log.success(` Downloaded: ${filename} (${(stats.size / 1024).toFixed(1)} KB)`);
139
+ return {
140
+ filename,
141
+ path: outputPath,
142
+ size: stats.size,
143
+ course_id: resource.course_id,
144
+ course_name: resource.course_name,
145
+ };
146
+ }
147
+ catch (err) {
148
+ log.warn(` Failed to download ${resource.name}: ${err instanceof Error ? err.message : String(err)}`);
149
+ return null;
150
+ }
151
+ }
152
+ materialsCmd
153
+ .command("list-all")
154
+ .description("List all materials/resources across all courses")
155
+ .option("--level <type>", "Course level: in_progress (default) | all", "in_progress")
156
+ .option("--output <format>", "Output format: json|csv|table|silent")
157
+ .action(async (options, command) => {
158
+ const apiContext = await createApiContext(options, command);
159
+ if (!apiContext) {
160
+ process.exitCode = 1;
161
+ return;
162
+ }
163
+ const classification = options.level === "all" ? undefined : "inprogress";
164
+ const courses = await getEnrolledCoursesApi(apiContext.session, {
165
+ classification,
166
+ });
167
+ // Get materials via WS API (no browser needed!)
168
+ const courseIds = courses.map(c => c.id);
169
+ const apiResources = await getResourcesByCoursesApi(apiContext.session, courseIds);
170
+ // Build a map of courseId -> course for quick lookup
171
+ const courseMap = new Map(courses.map(c => [c.id, c]));
172
+ const allMaterials = [];
173
+ for (const resource of apiResources) {
174
+ const course = courseMap.get(resource.courseId);
175
+ if (course) {
176
+ allMaterials.push({
177
+ course_id: resource.courseId,
178
+ course_name: course.fullname,
179
+ cmid: resource.cmid,
180
+ name: resource.name,
181
+ url: resource.url,
182
+ modType: resource.modType,
183
+ mimetype: resource.mimetype,
184
+ filesize: resource.filesize,
185
+ modified: resource.modified,
186
+ });
187
+ }
188
+ }
189
+ const output = {
190
+ status: "success",
191
+ timestamp: new Date().toISOString(),
192
+ level: options.level,
193
+ materials: allMaterials.map(m => ({
194
+ course_id: m.course_id,
195
+ course_name: m.course_name,
196
+ id: m.cmid,
197
+ name: m.name,
198
+ type: m.modType,
199
+ mimetype: m.mimetype,
200
+ filesize: m.filesize,
201
+ modified: m.modified ? new Date(m.modified * 1000).toISOString() : null,
202
+ url: m.url,
203
+ })),
204
+ summary: {
205
+ total_courses: courses.length,
206
+ total_materials: allMaterials.length,
207
+ by_type: allMaterials.reduce((acc, m) => {
208
+ acc[m.modType] = (acc[m.modType] || 0) + 1;
209
+ return acc;
210
+ }, {}),
211
+ },
212
+ };
213
+ console.log(JSON.stringify(output));
214
+ });
215
+ materialsCmd
216
+ .command("download")
217
+ .description("Download all materials from a specific course (requires browser)")
218
+ .argument("<course-id>", "Course ID")
219
+ .option("--output-dir <path>", "Output directory", "./downloads")
220
+ .action(async (courseId, options, command) => {
221
+ const context = await createSessionContext(options, command);
222
+ if (!context) {
223
+ process.exitCode = 1;
224
+ return;
225
+ }
226
+ const { log, page, session, browser, context: browserContext } = context;
227
+ try {
228
+ const courses = await getEnrolledCourses(page, session, log);
229
+ const course = courses.find(c => c.id === parseInt(courseId, 10));
230
+ if (!course) {
231
+ log.error(`Course not found: ${courseId}`);
232
+ process.exitCode = 1;
233
+ return;
234
+ }
235
+ // Navigate to course page to find materials
236
+ await page.goto(`https://ilearning.cycu.edu.tw/course/view.php?id=${course.id}`, { waitUntil: "domcontentloaded" });
237
+ // Find all resource links
238
+ const materials = [];
239
+ const resourceLinks = await page.$$eval('a[href*="/mod/resource/view.php"]', (links) => {
240
+ return links.map((a) => ({
241
+ url: a.href,
242
+ name: a.textContent?.trim() || "",
243
+ }));
244
+ });
245
+ for (const link of resourceLinks) {
246
+ const cmidMatch = link.url.match(/id=(\d+)/);
247
+ if (cmidMatch) {
248
+ materials.push({
249
+ course_id: course.id,
250
+ course_name: course.fullname,
251
+ cmid: cmidMatch[1],
252
+ name: link.name,
253
+ url: link.url,
254
+ modType: "resource",
255
+ });
256
+ }
257
+ }
258
+ log.info(`Found ${materials.length} materials in course: ${course.fullname}`);
259
+ const downloadedFiles = [];
260
+ for (const material of materials) {
261
+ const result = await downloadResource(page, material, options.outputDir, log);
262
+ if (result) {
263
+ downloadedFiles.push(result);
264
+ }
265
+ }
266
+ const summary = {
267
+ total_materials: materials.length,
268
+ downloaded: downloadedFiles.length,
269
+ skipped: materials.length - downloadedFiles.length,
270
+ total_size: downloadedFiles.reduce((sum, f) => sum + f.size, 0),
271
+ };
272
+ const output = {
273
+ status: "success",
274
+ timestamp: new Date().toISOString(),
275
+ downloaded_files: downloadedFiles.map(f => ({
276
+ filename: f.filename,
277
+ path: f.path,
278
+ size: f.size,
279
+ course_id: f.course_id,
280
+ course_name: f.course_name,
281
+ })),
282
+ summary,
283
+ };
284
+ console.log(JSON.stringify(output));
285
+ }
286
+ finally {
287
+ await closeBrowserSafely(browser, browserContext);
288
+ }
289
+ });
290
+ materialsCmd
291
+ .command("download-all")
292
+ .description("Download all materials from all courses (requires browser)")
293
+ .option("--output-dir <path>", "Output directory", "./downloads")
294
+ .option("--level <type>", "Course level: in_progress (default) | all", "in_progress")
295
+ .action(async (options, command) => {
296
+ const context = await createSessionContext(options, command);
297
+ if (!context) {
298
+ process.exitCode = 1;
299
+ return;
300
+ }
301
+ const { log, page, session, browser, context: browserContext } = context;
302
+ try {
303
+ const classification = options.level === "all" ? undefined : "inprogress";
304
+ const courses = await getEnrolledCourses(page, session, log, { classification });
305
+ log.info(`Scanning ${courses.length} courses for materials...`);
306
+ const allMaterials = [];
307
+ for (const course of courses) {
308
+ await page.goto(`https://ilearning.cycu.edu.tw/course/view.php?id=${course.id}`, { waitUntil: "domcontentloaded" });
309
+ const resourceLinks = await page.$$eval('a[href*="/mod/resource/view.php"]', (links) => {
310
+ return links.map((a) => ({
311
+ url: a.href,
312
+ name: a.textContent?.trim() || "",
313
+ }));
314
+ });
315
+ for (const link of resourceLinks) {
316
+ const cmidMatch = link.url.match(/id=(\d+)/);
317
+ if (cmidMatch) {
318
+ allMaterials.push({
319
+ course_id: course.id,
320
+ course_name: course.fullname,
321
+ cmid: cmidMatch[1],
322
+ name: link.name,
323
+ url: link.url,
324
+ modType: "resource",
325
+ });
326
+ }
327
+ }
328
+ }
329
+ log.info(`Found ${allMaterials.length} materials across ${courses.length} courses`);
330
+ const downloadedFiles = [];
331
+ for (const material of allMaterials) {
332
+ const result = await downloadResource(page, material, options.outputDir, log);
333
+ if (result) {
334
+ downloadedFiles.push(result);
335
+ }
336
+ }
337
+ const summary = {
338
+ total_courses: courses.length,
339
+ total_materials: allMaterials.length,
340
+ downloaded: downloadedFiles.length,
341
+ skipped: allMaterials.length - downloadedFiles.length,
342
+ total_size: downloadedFiles.reduce((sum, f) => sum + f.size, 0),
343
+ };
344
+ const output = {
345
+ status: "success",
346
+ timestamp: new Date().toISOString(),
347
+ downloaded_files: downloadedFiles.map(f => ({
348
+ filename: f.filename,
349
+ path: f.path,
350
+ size: f.size,
351
+ course_id: f.course_id,
352
+ course_name: f.course_name,
353
+ })),
354
+ summary,
355
+ };
356
+ console.log(JSON.stringify(output));
357
+ }
358
+ finally {
359
+ await closeBrowserSafely(browser, browserContext);
360
+ }
361
+ });
362
+ }
@@ -0,0 +1,3 @@
1
+ import { Command } from "commander";
2
+ export declare function registerQuizzesCommand(program: Command): void;
3
+ //# sourceMappingURL=quizzes.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"quizzes.d.ts","sourceRoot":"","sources":["../../../src/src/commands/quizzes.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAYpC,wBAAgB,sBAAsB,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CAuL7D"}
@@ -0,0 +1,160 @@
1
+ import { getBaseDir } from "../lib/utils.js";
2
+ import { getEnrolledCoursesApi, getQuizzesByCoursesApi } from "../lib/moodle.js";
3
+ import { createLogger } from "../lib/logger.js";
4
+ import { launchAuthenticated } from "../lib/auth.js";
5
+ import { extractSessionInfo } from "../lib/session.js";
6
+ import { closeBrowserSafely } from "../lib/auth.js";
7
+ import { formatAndOutput } from "../index.js";
8
+ import { loadWsToken } from "../lib/token.js";
9
+ import path from "node:path";
10
+ import fs from "node:fs";
11
+ export function registerQuizzesCommand(program) {
12
+ const quizzesCmd = program.command("quizzes");
13
+ quizzesCmd.description("Quiz operations");
14
+ function getOutputFormat(command) {
15
+ const opts = command.optsWithGlobals();
16
+ return opts.output || "json";
17
+ }
18
+ // Pure API context - no browser required (fast!)
19
+ async function createApiContext(options, command) {
20
+ const opts = command?.optsWithGlobals ? command.optsWithGlobals() : options;
21
+ const outputFormat = getOutputFormat(command || { optsWithGlobals: () => ({ output: "json" }) });
22
+ const silent = outputFormat === "json" && !opts.verbose;
23
+ const log = createLogger(opts.verbose, silent);
24
+ const baseDir = getBaseDir();
25
+ const sessionPath = path.resolve(baseDir, ".auth", "storage-state.json");
26
+ // Check if session exists
27
+ if (!fs.existsSync(sessionPath)) {
28
+ log.error("未找到登入 session。請先執行 'openape auth login' 進行登入。");
29
+ log.info(`Session 預期位置: ${sessionPath}`);
30
+ return null;
31
+ }
32
+ // Try to load WS token
33
+ const wsToken = loadWsToken(sessionPath);
34
+ if (!wsToken) {
35
+ log.error("未找到 WS token。請先執行 'openape auth login' 進行登入。");
36
+ return null;
37
+ }
38
+ return {
39
+ log,
40
+ session: {
41
+ wsToken,
42
+ moodleBaseUrl: "https://ilearning.cycu.edu.tw",
43
+ },
44
+ };
45
+ }
46
+ // Helper function to create session context (for open command only)
47
+ async function createSessionContext(options, command) {
48
+ const opts = command?.optsWithGlobals ? command.optsWithGlobals() : options;
49
+ const outputFormat = getOutputFormat(command || { optsWithGlobals: () => ({ output: "json" }) });
50
+ const silent = outputFormat === "json" && !opts.verbose;
51
+ const log = createLogger(opts.verbose, silent);
52
+ const baseDir = getBaseDir();
53
+ const sessionPath = path.resolve(baseDir, ".auth", "storage-state.json");
54
+ if (!fs.existsSync(sessionPath)) {
55
+ log.error("未找到登入 session。請先執行 'openape auth login' 進行登入。");
56
+ return null;
57
+ }
58
+ const config = {
59
+ username: "",
60
+ password: "",
61
+ courseUrl: "",
62
+ moodleBaseUrl: "https://ilearning.cycu.edu.tw",
63
+ headless: !options.headed,
64
+ slowMo: 0,
65
+ authStatePath: sessionPath,
66
+ ollamaBaseUrl: "",
67
+ };
68
+ log.info("啟動瀏覽器...");
69
+ const { browser, context, page } = await launchAuthenticated(config, log);
70
+ try {
71
+ const session = await extractSessionInfo(page, config, log);
72
+ return { log, page, session, browser, context };
73
+ }
74
+ catch (err) {
75
+ await context.close();
76
+ await browser.close();
77
+ throw err;
78
+ }
79
+ }
80
+ quizzesCmd
81
+ .command("list")
82
+ .description("List quizzes in a course")
83
+ .argument("<course-id>", "Course ID")
84
+ .option("--available-only", "Show only available quizzes")
85
+ .option("--output <format>", "Output format: json|csv|table|silent")
86
+ .action(async (courseId, options, command) => {
87
+ const output = getOutputFormat(command);
88
+ const apiContext = await createApiContext(options, command);
89
+ if (!apiContext) {
90
+ process.exitCode = 1;
91
+ return;
92
+ }
93
+ const quizzes = await getQuizzesByCoursesApi(apiContext.session, [parseInt(courseId, 10)]);
94
+ // Note: API doesn't provide completion status, so --available-only shows all
95
+ if (options.availableOnly) {
96
+ apiContext.log.warn("--available-only is not supported in API mode, showing all quizzes");
97
+ }
98
+ formatAndOutput(quizzes, output, apiContext.log);
99
+ });
100
+ quizzesCmd
101
+ .command("list-all")
102
+ .description("List all available quizzes across all courses")
103
+ .option("--level <type>", "Course level: in_progress (default) | all", "in_progress")
104
+ .option("--output <format>", "Output format: json|csv|table|silent")
105
+ .action(async (options, command) => {
106
+ const output = getOutputFormat(command);
107
+ const apiContext = await createApiContext(options, command);
108
+ if (!apiContext) {
109
+ process.exitCode = 1;
110
+ return;
111
+ }
112
+ const classification = options.level === "all" ? undefined : "inprogress";
113
+ const courses = await getEnrolledCoursesApi(apiContext.session, {
114
+ classification,
115
+ });
116
+ // Get quizzes via WS API (no browser needed!)
117
+ const courseIds = courses.map(c => c.id);
118
+ const apiQuizzes = await getQuizzesByCoursesApi(apiContext.session, courseIds);
119
+ // Build a map of courseId -> course for quick lookup
120
+ const courseMap = new Map(courses.map(c => [c.id, c]));
121
+ const allQuizzes = [];
122
+ for (const q of apiQuizzes) {
123
+ const course = courseMap.get(q.courseId);
124
+ if (course) {
125
+ allQuizzes.push({
126
+ courseName: course.fullname,
127
+ name: q.name,
128
+ url: q.url,
129
+ cmid: q.cmid,
130
+ isComplete: q.isComplete,
131
+ });
132
+ }
133
+ }
134
+ apiContext.log.info(`\n總計發現 ${allQuizzes.length} 個測驗。`);
135
+ formatAndOutput(allQuizzes, output, apiContext.log);
136
+ });
137
+ quizzesCmd
138
+ .command("open")
139
+ .description("Open a quiz URL in browser (manual mode)")
140
+ .argument("<quiz-url>", "Quiz URL")
141
+ .option("--headed", "Run browser in visible mode (default: true)")
142
+ .action(async (quizUrl, options, command) => {
143
+ const context = await createSessionContext({ ...options, headed: true }, command);
144
+ if (!context) {
145
+ process.exitCode = 1;
146
+ return;
147
+ }
148
+ const { log, page, browser, context: browserContext } = context;
149
+ try {
150
+ log.info(`導航至測驗頁面: ${quizUrl}`);
151
+ await page.goto(quizUrl, { waitUntil: "domcontentloaded" });
152
+ log.info("瀏覽器已開啟,請手動完成測驗。");
153
+ log.info("按 Ctrl+C 關閉瀏覽器。");
154
+ await new Promise(() => { });
155
+ }
156
+ finally {
157
+ await closeBrowserSafely(browser, browserContext);
158
+ }
159
+ });
160
+ }
@@ -0,0 +1,3 @@
1
+ import { Command } from "commander";
2
+ export declare function registerSkillsCommand(program: Command): void;
3
+ //# sourceMappingURL=skills.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"skills.d.ts","sourceRoot":"","sources":["../../../src/src/commands/skills.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AA6CpC,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CA0E5D"}