@mo7yw4ng/openape 1.0.4 → 1.0.5

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 (42) hide show
  1. package/esm/deno.js +1 -1
  2. package/esm/src/commands/announcements.js +3 -3
  3. package/esm/src/commands/auth.d.ts.map +1 -1
  4. package/esm/src/commands/auth.js +24 -14
  5. package/esm/src/commands/calendar.js +3 -3
  6. package/esm/src/commands/courses.js +3 -3
  7. package/esm/src/commands/forums.js +3 -3
  8. package/esm/src/commands/grades.js +3 -3
  9. package/esm/src/commands/materials.d.ts.map +1 -1
  10. package/esm/src/commands/materials.js +114 -191
  11. package/esm/src/commands/quizzes.d.ts.map +1 -1
  12. package/esm/src/commands/quizzes.js +33 -22
  13. package/esm/src/commands/videos.js +5 -5
  14. package/esm/src/lib/auth.js +2 -2
  15. package/esm/src/lib/logger.d.ts +1 -1
  16. package/esm/src/lib/logger.d.ts.map +1 -1
  17. package/esm/src/lib/logger.js +7 -4
  18. package/esm/src/lib/moodle.d.ts +4 -0
  19. package/esm/src/lib/moodle.d.ts.map +1 -1
  20. package/esm/src/lib/moodle.js +19 -2
  21. package/package.json +1 -1
  22. package/script/deno.js +1 -1
  23. package/script/src/commands/announcements.js +3 -3
  24. package/script/src/commands/auth.d.ts.map +1 -1
  25. package/script/src/commands/auth.js +24 -14
  26. package/script/src/commands/calendar.js +3 -3
  27. package/script/src/commands/courses.js +3 -3
  28. package/script/src/commands/forums.js +3 -3
  29. package/script/src/commands/grades.js +3 -3
  30. package/script/src/commands/materials.d.ts.map +1 -1
  31. package/script/src/commands/materials.js +111 -188
  32. package/script/src/commands/quizzes.d.ts.map +1 -1
  33. package/script/src/commands/quizzes.js +32 -21
  34. package/script/src/commands/videos.js +5 -5
  35. package/script/src/lib/auth.js +2 -2
  36. package/script/src/lib/logger.d.ts +1 -1
  37. package/script/src/lib/logger.d.ts.map +1 -1
  38. package/script/src/lib/logger.js +7 -4
  39. package/script/src/lib/moodle.d.ts +4 -0
  40. package/script/src/lib/moodle.d.ts.map +1 -1
  41. package/script/src/lib/moodle.js +20 -2
  42. package/skills/openape/SKILL.md +4 -2
package/esm/deno.js CHANGED
@@ -1,6 +1,6 @@
1
1
  export default {
2
2
  "name": "@openape/openape",
3
- "version": "1.0.4",
3
+ "version": "1.0.5",
4
4
  "description": "CLI tool for CYCU i-Learning platform",
5
5
  "license": "MIT",
6
6
  "exports": "./src/index.ts",
@@ -17,19 +17,19 @@ export function registerAnnouncementsCommand(program) {
17
17
  const opts = command?.optsWithGlobals ? command.optsWithGlobals() : options;
18
18
  const outputFormat = getOutputFormat(command || { optsWithGlobals: () => ({ output: "json" }) });
19
19
  const silent = outputFormat === "json" && !opts.verbose;
20
- const log = createLogger(opts.verbose, silent);
20
+ const log = createLogger(opts.verbose, silent, outputFormat);
21
21
  const baseDir = getBaseDir();
22
22
  const sessionPath = path.resolve(baseDir, ".auth", "storage-state.json");
23
23
  // Check if session exists
24
24
  if (!fs.existsSync(sessionPath)) {
25
- log.error("未找到登入 session。請先執行 'openape auth login' 進行登入。");
25
+ console.error("未找到登入 session。請先執行 'openape login' 進行登入。");
26
26
  log.info(`Session 預期位置: ${sessionPath}`);
27
27
  return null;
28
28
  }
29
29
  // Try to load WS token
30
30
  const wsToken = loadWsToken(sessionPath);
31
31
  if (!wsToken) {
32
- log.error("未找到 WS token。請先執行 'openape auth login' 進行登入。");
32
+ console.error("未找到 WS token。請先執行 'openape login' 進行登入。");
33
33
  return null;
34
34
  }
35
35
  return {
@@ -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;AAWpC,wBAAgB,eAAe,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CAuRtD"}
1
+ {"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../../../src/src/commands/auth.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAWpC,wBAAgB,eAAe,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CAkStD"}
@@ -129,31 +129,41 @@ export function registerCommand(program) {
129
129
  catch {
130
130
  // Ignore sesskey extraction errors
131
131
  }
132
- // Try to acquire WS token
132
+ // Acquire WS token
133
+ let wsToken;
133
134
  try {
134
- const config = {
135
- username: "",
136
- password: "",
137
- courseUrl: "",
138
- moodleBaseUrl: "https://ilearning.cycu.edu.tw",
139
- headless: false,
140
- slowMo: 0,
141
- authStatePath: sessionPath,
142
- ollamaBaseUrl: "",
143
- };
144
- const wsToken = await acquireWsToken(page, config, log);
135
+ wsToken = await acquireWsToken(page, { moodleBaseUrl: "https://ilearning.cycu.edu.tw" }, log);
145
136
  saveWsToken(sessionPath, wsToken);
146
137
  }
147
138
  catch {
148
139
  // WS token is optional, ignore errors
149
140
  }
150
141
  const stats = fs.statSync(sessionPath);
142
+ // Get user info via WS API
143
+ let user;
144
+ try {
145
+ if (wsToken) {
146
+ const siteInfo = await getSiteInfoApi({
147
+ wsToken,
148
+ moodleBaseUrl: "https://ilearning.cycu.edu.tw",
149
+ });
150
+ user = {
151
+ userid: siteInfo.userid,
152
+ username: siteInfo.username,
153
+ fullname: siteInfo.fullname,
154
+ };
155
+ }
156
+ }
157
+ catch {
158
+ // Ignore
159
+ }
151
160
  const result = {
152
161
  status: "success",
153
162
  message: "Login successful",
154
163
  session_path: sessionPath,
155
164
  session_size: stats.size,
156
- updated: true
165
+ updated: true,
166
+ ...(user ? { user } : {}),
157
167
  };
158
168
  console.log(JSON.stringify(result, null, 2));
159
169
  }
@@ -247,7 +257,7 @@ export function registerCommand(program) {
247
257
  status: "error",
248
258
  error: "Session not found",
249
259
  session_path: sessionPath,
250
- hint: "Run 'openape auth login' first"
260
+ hint: "Run 'openape login' first"
251
261
  };
252
262
  console.log(JSON.stringify(result, null, 2));
253
263
  }
@@ -17,19 +17,19 @@ export function registerCalendarCommand(program) {
17
17
  const opts = command?.optsWithGlobals ? command.optsWithGlobals() : options;
18
18
  const outputFormat = getOutputFormat(command || { optsWithGlobals: () => ({ output: "json" }) });
19
19
  const silent = outputFormat === "json" && !opts.verbose;
20
- const log = createLogger(opts.verbose, silent);
20
+ const log = createLogger(opts.verbose, silent, outputFormat);
21
21
  const baseDir = getBaseDir();
22
22
  const sessionPath = path.resolve(baseDir, ".auth", "storage-state.json");
23
23
  // Check if session exists
24
24
  if (!fs.existsSync(sessionPath)) {
25
- log.error("未找到登入 session。請先執行 'openape auth login' 進行登入。");
25
+ console.error("未找到登入 session。請先執行 'openape login' 進行登入。");
26
26
  log.info(`Session 預期位置: ${sessionPath}`);
27
27
  return null;
28
28
  }
29
29
  // Try to load WS token
30
30
  const wsToken = loadWsToken(sessionPath);
31
31
  if (!wsToken) {
32
- log.error("未找到 WS token。請先執行 'openape auth login' 進行登入。");
32
+ console.error("未找到 WS token。請先執行 'openape login' 進行登入。");
33
33
  return null;
34
34
  }
35
35
  return {
@@ -18,19 +18,19 @@ export function registerCoursesCommand(program) {
18
18
  const opts = command?.optsWithGlobals ? command.optsWithGlobals() : options;
19
19
  const outputFormat = getOutputFormat(command || { optsWithGlobals: () => ({ output: "json" }) });
20
20
  const silent = outputFormat === "json" && !opts.verbose;
21
- const log = createLogger(opts.verbose, silent);
21
+ const log = createLogger(opts.verbose, silent, outputFormat);
22
22
  const baseDir = getBaseDir();
23
23
  const sessionPath = path.resolve(baseDir, ".auth", "storage-state.json");
24
24
  // Check if session exists
25
25
  if (!fs.existsSync(sessionPath)) {
26
- log.error("未找到登入 session。請先執行 'openape auth login' 進行登入。");
26
+ console.error("未找到登入 session。請先執行 'openape login' 進行登入。");
27
27
  log.info(`Session 預期位置: ${sessionPath}`);
28
28
  return null;
29
29
  }
30
30
  // Try to load WS token
31
31
  const wsToken = loadWsToken(sessionPath);
32
32
  if (!wsToken) {
33
- log.error("未找到 WS token。請先執行 'openape auth login' 進行登入。");
33
+ console.error("未找到 WS token。請先執行 'openape login' 進行登入。");
34
34
  return null;
35
35
  }
36
36
  return {
@@ -12,19 +12,19 @@ export function registerForumsCommand(program) {
12
12
  const opts = command?.optsWithGlobals ? command.optsWithGlobals() : options;
13
13
  const outputFormat = getOutputFormat(command || { optsWithGlobals: () => ({ output: "json" }) });
14
14
  const silent = outputFormat === "json" && !opts.verbose;
15
- const log = createLogger(opts.verbose, silent);
15
+ const log = createLogger(opts.verbose, silent, outputFormat);
16
16
  const baseDir = getBaseDir();
17
17
  const sessionPath = path.resolve(baseDir, ".auth", "storage-state.json");
18
18
  // Check if session exists
19
19
  if (!fs.existsSync(sessionPath)) {
20
- log.error("未找到登入 session。請先執行 'openape auth login' 進行登入。");
20
+ console.error("未找到登入 session。請先執行 'openape login' 進行登入。");
21
21
  log.info(`Session 預期位置: ${sessionPath}`);
22
22
  return null;
23
23
  }
24
24
  // Try to load WS token
25
25
  const wsToken = loadWsToken(sessionPath);
26
26
  if (!wsToken) {
27
- log.error("未找到 WS token。請先執行 'openape auth login' 進行登入。");
27
+ console.error("未找到 WS token。請先執行 'openape login' 進行登入。");
28
28
  return null;
29
29
  }
30
30
  // Try to load sesskey from cache
@@ -17,19 +17,19 @@ export function registerGradesCommand(program) {
17
17
  const opts = command?.optsWithGlobals ? command.optsWithGlobals() : options;
18
18
  const outputFormat = getOutputFormat(command || { optsWithGlobals: () => ({ output: "json" }) });
19
19
  const silent = outputFormat === "json" && !opts.verbose;
20
- const log = createLogger(opts.verbose, silent);
20
+ const log = createLogger(opts.verbose, silent, outputFormat);
21
21
  const baseDir = getBaseDir();
22
22
  const sessionPath = path.resolve(baseDir, ".auth", "storage-state.json");
23
23
  // Check if session exists
24
24
  if (!fs.existsSync(sessionPath)) {
25
- log.error("未找到登入 session。請先執行 'openape auth login' 進行登入。");
25
+ console.error("未找到登入 session。請先執行 'openape login' 進行登入。");
26
26
  log.info(`Session 預期位置: ${sessionPath}`);
27
27
  return null;
28
28
  }
29
29
  // Try to load WS token
30
30
  const wsToken = loadWsToken(sessionPath);
31
31
  if (!wsToken) {
32
- log.error("未找到 WS token。請先執行 'openape auth login' 進行登入。");
32
+ console.error("未找到 WS token。請先執行 'openape login' 進行登入。");
33
33
  return null;
34
34
  }
35
35
  return {
@@ -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;AAgCpC,wBAAgB,wBAAwB,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CA8iB/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;AA4BpC,wBAAgB,wBAAwB,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CAid/D"}
@@ -1,75 +1,24 @@
1
- import { getOutputFormat, sanitizeFilename, getSessionPath, formatFileSize } from "../lib/utils.js";
2
- import { getEnrolledCourses, getEnrolledCoursesApi, getResourcesByCoursesApi, updateActivityCompletionStatusManually, getSiteInfoApi, moodleApiCall } from "../lib/moodle.js";
3
- import { createLogger } from "../lib/logger.js";
4
- import { launchAuthenticated, createApiContext } from "../lib/auth.js";
5
- import { extractSessionInfo } from "../lib/session.js";
6
- import { closeBrowserSafely } from "../lib/auth.js";
1
+ import { getOutputFormat, sanitizeFilename, formatFileSize } from "../lib/utils.js";
2
+ import { getEnrolledCoursesApi, getResourcesByCoursesApi, updateActivityCompletionStatusManually, getSiteInfoApi, moodleApiCall } from "../lib/moodle.js";
3
+ import { createApiContext } from "../lib/auth.js";
7
4
  import { formatAndOutput } from "../index.js";
8
5
  import path from "node:path";
9
6
  import fs from "node:fs";
10
7
  export function registerMaterialsCommand(program) {
11
8
  const materialsCmd = program.command("materials");
12
9
  materialsCmd.description("Material/resource operations");
13
- // Helper function to create session context (for download commands)
14
- async function createSessionContext(options, command) {
15
- const opts = command?.optsWithGlobals ? command.optsWithGlobals() : options;
16
- const outputFormat = getOutputFormat(command || { optsWithGlobals: () => ({ output: "json" }) });
17
- const silent = outputFormat === "json" && !opts.verbose;
18
- const log = createLogger(opts.verbose, silent);
19
- const sessionPath = getSessionPath();
20
- if (!fs.existsSync(sessionPath)) {
21
- log.error("未找到登入 session。請先執行 'openape auth login' 進行登入。");
22
- return null;
23
- }
24
- const config = {
25
- username: "",
26
- password: "",
27
- courseUrl: "",
28
- moodleBaseUrl: "https://ilearning.cycu.edu.tw",
29
- headless: !options.headed,
30
- slowMo: 0,
31
- authStatePath: sessionPath,
32
- ollamaBaseUrl: "",
33
- };
34
- log.info("啟動瀏覽器...");
35
- const { browser, context, page } = await launchAuthenticated(config, log);
36
- try {
37
- const session = await extractSessionInfo(page, config, log);
38
- return { log, page, session, browser, context };
39
- }
40
- catch (err) {
41
- await context.close();
42
- await browser.close();
43
- throw err;
44
- }
45
- }
46
- // Helper to download a single resource
47
- async function downloadResource(page, resource, outputDir, log) {
10
+ // Helper to download a single resource via HTTP (no browser needed)
11
+ async function downloadResourceHttp(resource, outputDir, log, token) {
48
12
  try {
49
13
  // Only download resource type (skip url)
50
14
  if (resource.modType !== "resource") {
51
15
  log.debug(` Skipping ${resource.modType}: ${resource.name}`);
52
16
  return null;
53
17
  }
54
- // Create course directory
55
18
  const courseDir = path.join(outputDir, sanitizeFilename(resource.course_name));
56
19
  await fs.promises.mkdir(courseDir, { recursive: true });
57
- // Navigate to resource page
58
- log.debug(` Downloading: ${resource.name}`);
59
- await page.goto(resource.url, { waitUntil: "domcontentloaded", timeout: 30000 });
60
- // Try to find download link on the page
61
- const downloadLinks = await page.$$eval('a[href*="forcedownload=1"]', (links) => links.map((a) => a.href));
62
- if (downloadLinks.length === 0) {
63
- log.warn(` No download link found for: ${resource.name}`);
64
- return null;
65
- }
66
- // Download the first available file
67
- const downloadUrl = downloadLinks[0];
68
- // Extract filename from URL or use resource name
69
- const urlObj = new URL(downloadUrl);
70
- const filenameParam = urlObj.searchParams.get("filename");
71
- let filename = filenameParam || sanitizeFilename(resource.name);
72
- // Add extension if missing
20
+ // Build filename
21
+ let filename = sanitizeFilename(resource.name);
73
22
  if (resource.mimetype && !path.extname(filename)) {
74
23
  const extMap = {
75
24
  "application/pdf": ".pdf",
@@ -88,27 +37,54 @@ export function registerMaterialsCommand(program) {
88
37
  }
89
38
  }
90
39
  const outputPath = path.join(courseDir, filename);
91
- // Trigger download
92
- const downloadPromise = page.waitForEvent("download", { timeout: 30000 });
93
- await page.goto(downloadUrl, { waitUntil: "domcontentloaded" });
94
- const download = await downloadPromise;
95
- // Save file
96
- await download.saveAs(outputPath);
97
- const stats = await fs.promises.stat(outputPath);
98
- log.success(` Downloaded: ${filename} (${formatFileSize(stats.size, 1)} KB)`);
99
- return {
100
- filename,
101
- path: outputPath,
102
- size: stats.size,
103
- course_id: resource.course_id,
104
- course_name: resource.course_name,
105
- };
40
+ // Skip if already exists
41
+ if (fs.existsSync(outputPath)) {
42
+ log.debug(` Skipping (exists): ${filename}`);
43
+ const stats = await fs.promises.stat(outputPath);
44
+ return { filename, path: outputPath, size: stats.size, course_id: resource.course_id, course_name: resource.course_name };
45
+ }
46
+ // Download via HTTP with WS token
47
+ const separator = resource.url.includes("?") ? "&" : "?";
48
+ const downloadUrl = `${resource.url}${separator}token=${token}`;
49
+ log.debug(` Downloading: ${resource.name}`);
50
+ const response = await fetch(downloadUrl);
51
+ if (!response.ok) {
52
+ log.warn(` Failed to download ${resource.name}: HTTP ${response.status}`);
53
+ return null;
54
+ }
55
+ const arrayBuffer = await response.arrayBuffer();
56
+ const data = new Uint8Array(arrayBuffer);
57
+ await fs.promises.writeFile(outputPath, data);
58
+ log.success(` Downloaded: ${filename} (${formatFileSize(data.byteLength, 1)} KB)`);
59
+ return { filename, path: outputPath, size: data.byteLength, course_id: resource.course_id, course_name: resource.course_name };
106
60
  }
107
61
  catch (err) {
108
62
  log.warn(` Failed to download ${resource.name}: ${err instanceof Error ? err.message : String(err)}`);
109
63
  return null;
110
64
  }
111
65
  }
66
+ // Helper to build material list from API resources
67
+ function buildMaterialsList(courses, apiResources) {
68
+ const courseMap = new Map(courses.map(c => [c.id, c]));
69
+ const materials = [];
70
+ for (const resource of apiResources) {
71
+ const course = courseMap.get(resource.courseId);
72
+ if (course) {
73
+ materials.push({
74
+ course_id: resource.courseId,
75
+ course_name: course.fullname,
76
+ cmid: resource.cmid,
77
+ name: resource.name,
78
+ url: resource.url,
79
+ modType: resource.modType,
80
+ mimetype: resource.mimetype,
81
+ filesize: resource.filesize,
82
+ modified: resource.modified,
83
+ });
84
+ }
85
+ }
86
+ return materials;
87
+ }
112
88
  materialsCmd
113
89
  .command("list-all")
114
90
  .description("List all materials/resources across all courses")
@@ -174,150 +150,97 @@ export function registerMaterialsCommand(program) {
174
150
  });
175
151
  materialsCmd
176
152
  .command("download")
177
- .description("Download all materials from a specific course (requires browser)")
153
+ .description("Download all materials from a specific course")
178
154
  .argument("<course-id>", "Course ID")
179
155
  .option("--output-dir <path>", "Output directory", "./downloads")
180
156
  .action(async (courseId, options, command) => {
181
- const context = await createSessionContext(options, command);
182
- if (!context) {
157
+ const apiContext = await createApiContext(options, command);
158
+ if (!apiContext) {
183
159
  process.exitCode = 1;
184
160
  return;
185
161
  }
186
- const { log, page, session, browser, context: browserContext } = context;
187
- try {
188
- const courses = await getEnrolledCourses(page, session, log);
189
- const course = courses.find(c => c.id === parseInt(courseId, 10));
190
- if (!course) {
191
- log.error(`Course not found: ${courseId}`);
192
- process.exitCode = 1;
193
- return;
194
- }
195
- // Navigate to course page to find materials
196
- await page.goto(`https://ilearning.cycu.edu.tw/course/view.php?id=${course.id}`, { waitUntil: "domcontentloaded" });
197
- // Find all resource links
198
- const materials = [];
199
- const resourceLinks = await page.$$eval('a[href*="/mod/resource/view.php"]', (links) => {
200
- return links.map((a) => ({
201
- url: a.href,
202
- name: a.textContent?.trim() || "",
203
- }));
204
- });
205
- for (const link of resourceLinks) {
206
- const cmidMatch = link.url.match(/id=(\d+)/);
207
- if (cmidMatch) {
208
- materials.push({
209
- course_id: course.id,
210
- course_name: course.fullname,
211
- cmid: cmidMatch[1],
212
- name: link.name,
213
- url: link.url,
214
- modType: "resource",
215
- });
216
- }
217
- }
218
- log.info(`Found ${materials.length} materials in course: ${course.fullname}`);
219
- const downloadedFiles = [];
220
- for (const material of materials) {
221
- const result = await downloadResource(page, material, options.outputDir, log);
222
- if (result) {
223
- downloadedFiles.push(result);
224
- }
162
+ const { log, session } = apiContext;
163
+ const courses = await getEnrolledCoursesApi(session);
164
+ const course = courses.find((c) => c.id === parseInt(courseId, 10));
165
+ if (!course) {
166
+ log.error(`Course not found: ${courseId}`);
167
+ process.exitCode = 1;
168
+ return;
169
+ }
170
+ const apiResources = await getResourcesByCoursesApi(session, [course.id]);
171
+ const materials = buildMaterialsList(courses, apiResources);
172
+ log.info(`Found ${materials.length} materials in course: ${course.fullname}`);
173
+ const downloadedFiles = [];
174
+ for (const material of materials) {
175
+ const result = await downloadResourceHttp(material, options.outputDir, log, session.wsToken);
176
+ if (result) {
177
+ downloadedFiles.push(result);
225
178
  }
226
- const summary = {
179
+ }
180
+ const output = {
181
+ status: "success",
182
+ timestamp: new Date().toISOString(),
183
+ downloaded_files: downloadedFiles.map(f => ({
184
+ filename: f.filename,
185
+ path: f.path,
186
+ size: f.size,
187
+ course_id: f.course_id,
188
+ course_name: f.course_name,
189
+ })),
190
+ summary: {
227
191
  total_materials: materials.length,
228
192
  downloaded: downloadedFiles.length,
229
193
  skipped: materials.length - downloadedFiles.length,
230
194
  total_size: downloadedFiles.reduce((sum, f) => sum + f.size, 0),
231
- };
232
- const output = {
233
- status: "success",
234
- timestamp: new Date().toISOString(),
235
- downloaded_files: downloadedFiles.map(f => ({
236
- filename: f.filename,
237
- path: f.path,
238
- size: f.size,
239
- course_id: f.course_id,
240
- course_name: f.course_name,
241
- })),
242
- summary,
243
- };
244
- console.log(JSON.stringify(output));
245
- }
246
- finally {
247
- await closeBrowserSafely(browser, browserContext);
248
- }
195
+ },
196
+ };
197
+ console.log(JSON.stringify(output));
249
198
  });
250
199
  materialsCmd
251
200
  .command("download-all")
252
- .description("Download all materials from all courses (requires browser)")
201
+ .description("Download all materials from all courses")
253
202
  .option("--output-dir <path>", "Output directory", "./downloads")
254
203
  .option("--level <type>", "Course level: in_progress (default) | all", "in_progress")
255
204
  .action(async (options, command) => {
256
- const context = await createSessionContext(options, command);
257
- if (!context) {
205
+ const apiContext = await createApiContext(options, command);
206
+ if (!apiContext) {
258
207
  process.exitCode = 1;
259
208
  return;
260
209
  }
261
- const { log, page, session, browser, context: browserContext } = context;
262
- try {
263
- const classification = options.level === "all" ? undefined : "inprogress";
264
- const courses = await getEnrolledCourses(page, session, log, { classification });
265
- log.info(`Scanning ${courses.length} courses for materials...`);
266
- const allMaterials = [];
267
- for (const course of courses) {
268
- await page.goto(`https://ilearning.cycu.edu.tw/course/view.php?id=${course.id}`, { waitUntil: "domcontentloaded" });
269
- const resourceLinks = await page.$$eval('a[href*="/mod/resource/view.php"]', (links) => {
270
- return links.map((a) => ({
271
- url: a.href,
272
- name: a.textContent?.trim() || "",
273
- }));
274
- });
275
- for (const link of resourceLinks) {
276
- const cmidMatch = link.url.match(/id=(\d+)/);
277
- if (cmidMatch) {
278
- allMaterials.push({
279
- course_id: course.id,
280
- course_name: course.fullname,
281
- cmid: cmidMatch[1],
282
- name: link.name,
283
- url: link.url,
284
- modType: "resource",
285
- });
286
- }
287
- }
288
- }
289
- log.info(`Found ${allMaterials.length} materials across ${courses.length} courses`);
290
- const downloadedFiles = [];
291
- for (const material of allMaterials) {
292
- const result = await downloadResource(page, material, options.outputDir, log);
293
- if (result) {
294
- downloadedFiles.push(result);
295
- }
210
+ const { log, session } = apiContext;
211
+ const classification = options.level === "all" ? undefined : "inprogress";
212
+ const courses = await getEnrolledCoursesApi(session, { classification });
213
+ log.info(`Scanning ${courses.length} courses for materials...`);
214
+ const courseIds = courses.map((c) => c.id);
215
+ const apiResources = await getResourcesByCoursesApi(session, courseIds);
216
+ const allMaterials = buildMaterialsList(courses, apiResources);
217
+ log.info(`Found ${allMaterials.length} materials across ${courses.length} courses`);
218
+ const downloadedFiles = [];
219
+ for (const material of allMaterials) {
220
+ const result = await downloadResourceHttp(material, options.outputDir, log, session.wsToken);
221
+ if (result) {
222
+ downloadedFiles.push(result);
296
223
  }
297
- const summary = {
224
+ }
225
+ const output = {
226
+ status: "success",
227
+ timestamp: new Date().toISOString(),
228
+ downloaded_files: downloadedFiles.map(f => ({
229
+ filename: f.filename,
230
+ path: f.path,
231
+ size: f.size,
232
+ course_id: f.course_id,
233
+ course_name: f.course_name,
234
+ })),
235
+ summary: {
298
236
  total_courses: courses.length,
299
237
  total_materials: allMaterials.length,
300
238
  downloaded: downloadedFiles.length,
301
239
  skipped: allMaterials.length - downloadedFiles.length,
302
240
  total_size: downloadedFiles.reduce((sum, f) => sum + f.size, 0),
303
- };
304
- const output = {
305
- status: "success",
306
- timestamp: new Date().toISOString(),
307
- downloaded_files: downloadedFiles.map(f => ({
308
- filename: f.filename,
309
- path: f.path,
310
- size: f.size,
311
- course_id: f.course_id,
312
- course_name: f.course_name,
313
- })),
314
- summary,
315
- };
316
- console.log(JSON.stringify(output));
317
- }
318
- finally {
319
- await closeBrowserSafely(browser, browserContext);
320
- }
241
+ },
242
+ };
243
+ console.log(JSON.stringify(output));
321
244
  });
322
245
  materialsCmd
323
246
  .command("complete")
@@ -1 +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;AAwDpC,wBAAgB,sBAAsB,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CA0Q7D"}
1
+ {"version":3,"file":"quizzes.d.ts","sourceRoot":"","sources":["../../../src/src/commands/quizzes.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAyEpC,wBAAgB,sBAAsB,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CAoQ7D"}