@mo7yw4ng/openape 1.0.3 → 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 (87) hide show
  1. package/README.md +30 -5
  2. package/esm/deno.js +1 -1
  3. package/esm/src/commands/announcements.d.ts.map +1 -1
  4. package/esm/src/commands/announcements.js +16 -17
  5. package/esm/src/commands/assignments.d.ts +3 -0
  6. package/esm/src/commands/assignments.d.ts.map +1 -0
  7. package/esm/src/commands/assignments.js +230 -0
  8. package/esm/src/commands/auth.d.ts.map +1 -1
  9. package/esm/src/commands/auth.js +45 -15
  10. package/esm/src/commands/calendar.d.ts.map +1 -1
  11. package/esm/src/commands/calendar.js +20 -21
  12. package/esm/src/commands/courses.js +6 -6
  13. package/esm/src/commands/forums.d.ts.map +1 -1
  14. package/esm/src/commands/forums.js +128 -36
  15. package/esm/src/commands/grades.js +3 -3
  16. package/esm/src/commands/materials.d.ts.map +1 -1
  17. package/esm/src/commands/materials.js +115 -224
  18. package/esm/src/commands/quizzes.d.ts.map +1 -1
  19. package/esm/src/commands/quizzes.js +179 -68
  20. package/esm/src/commands/skills.d.ts.map +1 -1
  21. package/esm/src/commands/skills.js +4 -8
  22. package/esm/src/commands/upload.d.ts +3 -0
  23. package/esm/src/commands/upload.d.ts.map +1 -0
  24. package/esm/src/commands/upload.js +58 -0
  25. package/esm/src/commands/videos.d.ts.map +1 -1
  26. package/esm/src/commands/videos.js +10 -9
  27. package/esm/src/index.d.ts.map +1 -1
  28. package/esm/src/index.js +12 -1
  29. package/esm/src/lib/auth.d.ts +23 -1
  30. package/esm/src/lib/auth.d.ts.map +1 -1
  31. package/esm/src/lib/auth.js +36 -3
  32. package/esm/src/lib/logger.d.ts +1 -1
  33. package/esm/src/lib/logger.d.ts.map +1 -1
  34. package/esm/src/lib/logger.js +7 -4
  35. package/esm/src/lib/moodle.d.ts +183 -1
  36. package/esm/src/lib/moodle.d.ts.map +1 -1
  37. package/esm/src/lib/moodle.js +498 -13
  38. package/esm/src/lib/types.d.ts +81 -164
  39. package/esm/src/lib/types.d.ts.map +1 -1
  40. package/esm/src/lib/types.js +1 -0
  41. package/esm/src/lib/utils.d.ts +20 -0
  42. package/esm/src/lib/utils.d.ts.map +1 -1
  43. package/esm/src/lib/utils.js +48 -1
  44. package/package.json +1 -1
  45. package/script/deno.js +1 -1
  46. package/script/src/commands/announcements.d.ts.map +1 -1
  47. package/script/src/commands/announcements.js +15 -16
  48. package/script/src/commands/assignments.d.ts +3 -0
  49. package/script/src/commands/assignments.d.ts.map +1 -0
  50. package/script/src/commands/assignments.js +269 -0
  51. package/script/src/commands/auth.d.ts.map +1 -1
  52. package/script/src/commands/auth.js +44 -14
  53. package/script/src/commands/calendar.d.ts.map +1 -1
  54. package/script/src/commands/calendar.js +19 -20
  55. package/script/src/commands/courses.js +5 -5
  56. package/script/src/commands/forums.d.ts.map +1 -1
  57. package/script/src/commands/forums.js +128 -36
  58. package/script/src/commands/grades.js +3 -3
  59. package/script/src/commands/materials.d.ts.map +1 -1
  60. package/script/src/commands/materials.js +115 -224
  61. package/script/src/commands/quizzes.d.ts.map +1 -1
  62. package/script/src/commands/quizzes.js +177 -66
  63. package/script/src/commands/skills.d.ts.map +1 -1
  64. package/script/src/commands/skills.js +4 -8
  65. package/script/src/commands/upload.d.ts +3 -0
  66. package/script/src/commands/upload.d.ts.map +1 -0
  67. package/script/src/commands/upload.js +64 -0
  68. package/script/src/commands/videos.d.ts.map +1 -1
  69. package/script/src/commands/videos.js +10 -9
  70. package/script/src/index.d.ts.map +1 -1
  71. package/script/src/index.js +12 -1
  72. package/script/src/lib/auth.d.ts +23 -1
  73. package/script/src/lib/auth.d.ts.map +1 -1
  74. package/script/src/lib/auth.js +70 -3
  75. package/script/src/lib/logger.d.ts +1 -1
  76. package/script/src/lib/logger.d.ts.map +1 -1
  77. package/script/src/lib/logger.js +7 -4
  78. package/script/src/lib/moodle.d.ts +183 -1
  79. package/script/src/lib/moodle.d.ts.map +1 -1
  80. package/script/src/lib/moodle.js +511 -13
  81. package/script/src/lib/types.d.ts +81 -164
  82. package/script/src/lib/types.d.ts.map +1 -1
  83. package/script/src/lib/types.js +1 -0
  84. package/script/src/lib/utils.d.ts +20 -0
  85. package/script/src/lib/utils.d.ts.map +1 -1
  86. package/script/src/lib/utils.js +52 -0
  87. package/skills/openape/SKILL.md +74 -270
@@ -1,107 +1,24 @@
1
- import { getBaseDir, getOutputFormat, sanitizeFilename } 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 } 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
- import { loadWsToken } from "../lib/token.js";
9
5
  import path from "node:path";
10
6
  import fs from "node:fs";
11
7
  export function registerMaterialsCommand(program) {
12
8
  const materialsCmd = program.command("materials");
13
9
  materialsCmd.description("Material/resource operations");
14
- // Pure API context - no browser required (fast!)
15
- async function createApiContext(options, command) {
16
- const opts = command?.optsWithGlobals ? command.optsWithGlobals() : options;
17
- const outputFormat = getOutputFormat(command || { optsWithGlobals: () => ({ output: "json" }) });
18
- const silent = outputFormat === "json" && !opts.verbose;
19
- const log = createLogger(opts.verbose, silent);
20
- const baseDir = getBaseDir();
21
- const sessionPath = path.resolve(baseDir, ".auth", "storage-state.json");
22
- // Check if session exists
23
- if (!fs.existsSync(sessionPath)) {
24
- log.error("未找到登入 session。請先執行 'openape auth login' 進行登入。");
25
- log.info(`Session 預期位置: ${sessionPath}`);
26
- return null;
27
- }
28
- // Try to load WS token
29
- const wsToken = loadWsToken(sessionPath);
30
- if (!wsToken) {
31
- log.error("未找到 WS token。請先執行 'openape auth login' 進行登入。");
32
- return null;
33
- }
34
- return {
35
- log,
36
- session: {
37
- wsToken,
38
- moodleBaseUrl: "https://ilearning.cycu.edu.tw",
39
- },
40
- };
41
- }
42
- // Helper function to create session context (for download commands)
43
- async function createSessionContext(options, command) {
44
- const opts = command?.optsWithGlobals ? command.optsWithGlobals() : options;
45
- const outputFormat = getOutputFormat(command || { optsWithGlobals: () => ({ output: "json" }) });
46
- const silent = outputFormat === "json" && !opts.verbose;
47
- const log = createLogger(opts.verbose, silent);
48
- const baseDir = getBaseDir();
49
- const sessionPath = path.resolve(baseDir, ".auth", "storage-state.json");
50
- if (!fs.existsSync(sessionPath)) {
51
- log.error("未找到登入 session。請先執行 'openape auth login' 進行登入。");
52
- return null;
53
- }
54
- const config = {
55
- username: "",
56
- password: "",
57
- courseUrl: "",
58
- moodleBaseUrl: "https://ilearning.cycu.edu.tw",
59
- headless: !options.headed,
60
- slowMo: 0,
61
- authStatePath: sessionPath,
62
- ollamaBaseUrl: "",
63
- };
64
- log.info("啟動瀏覽器...");
65
- const { browser, context, page } = await launchAuthenticated(config, log);
66
- try {
67
- const session = await extractSessionInfo(page, config, log);
68
- return { log, page, session, browser, context };
69
- }
70
- catch (err) {
71
- await context.close();
72
- await browser.close();
73
- throw err;
74
- }
75
- }
76
- // Helper to download a single resource
77
- 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) {
78
12
  try {
79
13
  // Only download resource type (skip url)
80
14
  if (resource.modType !== "resource") {
81
15
  log.debug(` Skipping ${resource.modType}: ${resource.name}`);
82
16
  return null;
83
17
  }
84
- // Create course directory
85
18
  const courseDir = path.join(outputDir, sanitizeFilename(resource.course_name));
86
- if (!fs.existsSync(courseDir)) {
87
- fs.mkdirSync(courseDir, { recursive: true });
88
- }
89
- // Navigate to resource page
90
- log.debug(` Downloading: ${resource.name}`);
91
- await page.goto(resource.url, { waitUntil: "domcontentloaded", timeout: 30000 });
92
- // Try to find download link on the page
93
- const downloadLinks = await page.$$eval('a[href*="forcedownload=1"]', (links) => links.map((a) => a.href));
94
- if (downloadLinks.length === 0) {
95
- log.warn(` No download link found for: ${resource.name}`);
96
- return null;
97
- }
98
- // Download the first available file
99
- const downloadUrl = downloadLinks[0];
100
- // Extract filename from URL or use resource name
101
- const urlObj = new URL(downloadUrl);
102
- const filenameParam = urlObj.searchParams.get("filename");
103
- let filename = filenameParam || sanitizeFilename(resource.name);
104
- // Add extension if missing
19
+ await fs.promises.mkdir(courseDir, { recursive: true });
20
+ // Build filename
21
+ let filename = sanitizeFilename(resource.name);
105
22
  if (resource.mimetype && !path.extname(filename)) {
106
23
  const extMap = {
107
24
  "application/pdf": ".pdf",
@@ -120,27 +37,54 @@ export function registerMaterialsCommand(program) {
120
37
  }
121
38
  }
122
39
  const outputPath = path.join(courseDir, filename);
123
- // Trigger download
124
- const downloadPromise = page.waitForEvent("download", { timeout: 30000 });
125
- await page.goto(downloadUrl, { waitUntil: "domcontentloaded" });
126
- const download = await downloadPromise;
127
- // Save file
128
- await download.saveAs(outputPath);
129
- const stats = fs.statSync(outputPath);
130
- log.success(` Downloaded: ${filename} (${(stats.size / 1024).toFixed(1)} KB)`);
131
- return {
132
- filename,
133
- path: outputPath,
134
- size: stats.size,
135
- course_id: resource.course_id,
136
- course_name: resource.course_name,
137
- };
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 };
138
60
  }
139
61
  catch (err) {
140
62
  log.warn(` Failed to download ${resource.name}: ${err instanceof Error ? err.message : String(err)}`);
141
63
  return null;
142
64
  }
143
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
+ }
144
88
  materialsCmd
145
89
  .command("list-all")
146
90
  .description("List all materials/resources across all courses")
@@ -206,150 +150,97 @@ export function registerMaterialsCommand(program) {
206
150
  });
207
151
  materialsCmd
208
152
  .command("download")
209
- .description("Download all materials from a specific course (requires browser)")
153
+ .description("Download all materials from a specific course")
210
154
  .argument("<course-id>", "Course ID")
211
155
  .option("--output-dir <path>", "Output directory", "./downloads")
212
156
  .action(async (courseId, options, command) => {
213
- const context = await createSessionContext(options, command);
214
- if (!context) {
157
+ const apiContext = await createApiContext(options, command);
158
+ if (!apiContext) {
215
159
  process.exitCode = 1;
216
160
  return;
217
161
  }
218
- const { log, page, session, browser, context: browserContext } = context;
219
- try {
220
- const courses = await getEnrolledCourses(page, session, log);
221
- const course = courses.find(c => c.id === parseInt(courseId, 10));
222
- if (!course) {
223
- log.error(`Course not found: ${courseId}`);
224
- process.exitCode = 1;
225
- return;
226
- }
227
- // Navigate to course page to find materials
228
- await page.goto(`https://ilearning.cycu.edu.tw/course/view.php?id=${course.id}`, { waitUntil: "domcontentloaded" });
229
- // Find all resource links
230
- const materials = [];
231
- const resourceLinks = await page.$$eval('a[href*="/mod/resource/view.php"]', (links) => {
232
- return links.map((a) => ({
233
- url: a.href,
234
- name: a.textContent?.trim() || "",
235
- }));
236
- });
237
- for (const link of resourceLinks) {
238
- const cmidMatch = link.url.match(/id=(\d+)/);
239
- if (cmidMatch) {
240
- materials.push({
241
- course_id: course.id,
242
- course_name: course.fullname,
243
- cmid: cmidMatch[1],
244
- name: link.name,
245
- url: link.url,
246
- modType: "resource",
247
- });
248
- }
249
- }
250
- log.info(`Found ${materials.length} materials in course: ${course.fullname}`);
251
- const downloadedFiles = [];
252
- for (const material of materials) {
253
- const result = await downloadResource(page, material, options.outputDir, log);
254
- if (result) {
255
- downloadedFiles.push(result);
256
- }
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);
257
178
  }
258
- 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: {
259
191
  total_materials: materials.length,
260
192
  downloaded: downloadedFiles.length,
261
193
  skipped: materials.length - downloadedFiles.length,
262
194
  total_size: downloadedFiles.reduce((sum, f) => sum + f.size, 0),
263
- };
264
- const output = {
265
- status: "success",
266
- timestamp: new Date().toISOString(),
267
- downloaded_files: downloadedFiles.map(f => ({
268
- filename: f.filename,
269
- path: f.path,
270
- size: f.size,
271
- course_id: f.course_id,
272
- course_name: f.course_name,
273
- })),
274
- summary,
275
- };
276
- console.log(JSON.stringify(output));
277
- }
278
- finally {
279
- await closeBrowserSafely(browser, browserContext);
280
- }
195
+ },
196
+ };
197
+ console.log(JSON.stringify(output));
281
198
  });
282
199
  materialsCmd
283
200
  .command("download-all")
284
- .description("Download all materials from all courses (requires browser)")
201
+ .description("Download all materials from all courses")
285
202
  .option("--output-dir <path>", "Output directory", "./downloads")
286
203
  .option("--level <type>", "Course level: in_progress (default) | all", "in_progress")
287
204
  .action(async (options, command) => {
288
- const context = await createSessionContext(options, command);
289
- if (!context) {
205
+ const apiContext = await createApiContext(options, command);
206
+ if (!apiContext) {
290
207
  process.exitCode = 1;
291
208
  return;
292
209
  }
293
- const { log, page, session, browser, context: browserContext } = context;
294
- try {
295
- const classification = options.level === "all" ? undefined : "inprogress";
296
- const courses = await getEnrolledCourses(page, session, log, { classification });
297
- log.info(`Scanning ${courses.length} courses for materials...`);
298
- const allMaterials = [];
299
- for (const course of courses) {
300
- await page.goto(`https://ilearning.cycu.edu.tw/course/view.php?id=${course.id}`, { waitUntil: "domcontentloaded" });
301
- const resourceLinks = await page.$$eval('a[href*="/mod/resource/view.php"]', (links) => {
302
- return links.map((a) => ({
303
- url: a.href,
304
- name: a.textContent?.trim() || "",
305
- }));
306
- });
307
- for (const link of resourceLinks) {
308
- const cmidMatch = link.url.match(/id=(\d+)/);
309
- if (cmidMatch) {
310
- allMaterials.push({
311
- course_id: course.id,
312
- course_name: course.fullname,
313
- cmid: cmidMatch[1],
314
- name: link.name,
315
- url: link.url,
316
- modType: "resource",
317
- });
318
- }
319
- }
320
- }
321
- log.info(`Found ${allMaterials.length} materials across ${courses.length} courses`);
322
- const downloadedFiles = [];
323
- for (const material of allMaterials) {
324
- const result = await downloadResource(page, material, options.outputDir, log);
325
- if (result) {
326
- downloadedFiles.push(result);
327
- }
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);
328
223
  }
329
- 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: {
330
236
  total_courses: courses.length,
331
237
  total_materials: allMaterials.length,
332
238
  downloaded: downloadedFiles.length,
333
239
  skipped: allMaterials.length - downloadedFiles.length,
334
240
  total_size: downloadedFiles.reduce((sum, f) => sum + f.size, 0),
335
- };
336
- const output = {
337
- status: "success",
338
- timestamp: new Date().toISOString(),
339
- downloaded_files: downloadedFiles.map(f => ({
340
- filename: f.filename,
341
- path: f.path,
342
- size: f.size,
343
- course_id: f.course_id,
344
- course_name: f.course_name,
345
- })),
346
- summary,
347
- };
348
- console.log(JSON.stringify(output));
349
- }
350
- finally {
351
- await closeBrowserSafely(browser, browserContext);
352
- }
241
+ },
242
+ };
243
+ console.log(JSON.stringify(output));
353
244
  });
354
245
  materialsCmd
355
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;AAYpC,wBAAgB,sBAAsB,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CAuL7D"}
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"}