@mo7yw4ng/openape 1.0.2 → 1.0.4

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 (81) hide show
  1. package/README.md +37 -9
  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 +13 -14
  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 +1 -1
  9. package/esm/src/commands/auth.d.ts.map +1 -1
  10. package/esm/src/commands/auth.js +24 -8
  11. package/esm/src/commands/calendar.d.ts.map +1 -1
  12. package/esm/src/commands/calendar.js +17 -18
  13. package/esm/src/commands/courses.js +3 -3
  14. package/esm/src/commands/forums.d.ts.map +1 -1
  15. package/esm/src/commands/forums.js +132 -45
  16. package/esm/src/commands/materials.d.ts.map +1 -1
  17. package/esm/src/commands/materials.js +176 -48
  18. package/esm/src/commands/quizzes.d.ts.map +1 -1
  19. package/esm/src/commands/quizzes.js +165 -65
  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 +113 -79
  27. package/esm/src/index.d.ts.map +1 -1
  28. package/esm/src/index.js +14 -3
  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/moodle.d.ts +226 -2
  33. package/esm/src/lib/moodle.d.ts.map +1 -1
  34. package/esm/src/lib/moodle.js +648 -37
  35. package/esm/src/lib/types.d.ts +82 -164
  36. package/esm/src/lib/types.d.ts.map +1 -1
  37. package/esm/src/lib/types.js +1 -0
  38. package/esm/src/lib/utils.d.ts +40 -0
  39. package/esm/src/lib/utils.d.ts.map +1 -1
  40. package/esm/src/lib/utils.js +82 -4
  41. package/package.json +1 -2
  42. package/script/deno.js +1 -1
  43. package/script/src/commands/announcements.d.ts.map +1 -1
  44. package/script/src/commands/announcements.js +12 -13
  45. package/script/src/commands/assignments.d.ts +3 -0
  46. package/script/src/commands/assignments.d.ts.map +1 -0
  47. package/script/src/commands/assignments.js +269 -0
  48. package/script/src/commands/auth.d.ts +1 -1
  49. package/script/src/commands/auth.d.ts.map +1 -1
  50. package/script/src/commands/auth.js +24 -8
  51. package/script/src/commands/calendar.d.ts.map +1 -1
  52. package/script/src/commands/calendar.js +16 -17
  53. package/script/src/commands/courses.js +2 -2
  54. package/script/src/commands/forums.d.ts.map +1 -1
  55. package/script/src/commands/forums.js +132 -45
  56. package/script/src/commands/materials.d.ts.map +1 -1
  57. package/script/src/commands/materials.js +177 -49
  58. package/script/src/commands/quizzes.d.ts.map +1 -1
  59. package/script/src/commands/quizzes.js +163 -63
  60. package/script/src/commands/skills.d.ts.map +1 -1
  61. package/script/src/commands/skills.js +4 -8
  62. package/script/src/commands/upload.d.ts +3 -0
  63. package/script/src/commands/upload.d.ts.map +1 -0
  64. package/script/src/commands/upload.js +64 -0
  65. package/script/src/commands/videos.d.ts.map +1 -1
  66. package/script/src/commands/videos.js +114 -80
  67. package/script/src/index.d.ts.map +1 -1
  68. package/script/src/index.js +13 -2
  69. package/script/src/lib/auth.d.ts +23 -1
  70. package/script/src/lib/auth.d.ts.map +1 -1
  71. package/script/src/lib/auth.js +70 -3
  72. package/script/src/lib/moodle.d.ts +226 -2
  73. package/script/src/lib/moodle.d.ts.map +1 -1
  74. package/script/src/lib/moodle.js +663 -37
  75. package/script/src/lib/types.d.ts +82 -164
  76. package/script/src/lib/types.d.ts.map +1 -1
  77. package/script/src/lib/types.js +1 -0
  78. package/script/src/lib/utils.d.ts +40 -0
  79. package/script/src/lib/utils.d.ts.map +1 -1
  80. package/script/src/lib/utils.js +89 -3
  81. package/skills/openape/SKILL.md +73 -291
@@ -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)")
@@ -283,7 +316,7 @@ export function registerVideosCommand(program) {
283
316
  const completed = downloaded.filter(d => d.success).length;
284
317
  const failed = downloaded.filter(d => !d.success).length;
285
318
  log.info(`\n執行結果: ${completed} 成功, ${failed} 失敗`);
286
- const output = {
319
+ console.log(JSON.stringify({
287
320
  status: "success",
288
321
  timestamp: new Date().toISOString(),
289
322
  course_id: courseId,
@@ -291,9 +324,10 @@ export function registerVideosCommand(program) {
291
324
  total_videos: videos.length,
292
325
  downloaded: completed,
293
326
  failed,
294
- videos: downloaded,
295
- };
296
- console.log(JSON.stringify(output));
327
+ }));
328
+ for (const v of downloaded) {
329
+ console.log(JSON.stringify(v));
330
+ }
297
331
  }
298
332
  finally {
299
333
  await closeBrowserSafely(browser, browserContext);
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/src/index.ts"],"names":[],"mappings":";AACA,OAAO,sBAAsB,CAAC;AAO9B,OAAO,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAC;AAC/C,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,EAAE,WAAW,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AAoEnF;;GAEG;AACH,wBAAsB,oBAAoB,CACxC,OAAO,EAAE;IACP,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,WAAW,CAAC,EAAE,OAAO,CAAC;CACvB,GACA,OAAO,CAAC;IAAE,MAAM,EAAE,SAAS,CAAC;IAAC,GAAG,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,OAAO,iBAAiB,EAAE,IAAI,CAAC;IAAC,OAAO,EAAE,WAAW,CAAA;CAAE,GAAG,IAAI,CAAC,CAoBhH;AAED;;;GAGG;AACH,wBAAgB,eAAe,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/D,IAAI,EAAE,CAAC,GAAG,CAAC,EAAE,EACb,MAAM,EAAE,YAAY,EACpB,GAAG,EAAE,MAAM,GACV,IAAI,CAmBN;AA2CD,OAAO,EAAE,YAAY,EAAE,KAAK,SAAS,EAAE,KAAK,MAAM,EAAE,KAAK,WAAW,EAAE,KAAK,YAAY,EAAE,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/src/index.ts"],"names":[],"mappings":";AACA,OAAO,sBAAsB,CAAC;AAO9B,OAAO,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAC;AAC/C,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,EAAE,WAAW,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AAwEnF;;GAEG;AACH,wBAAsB,oBAAoB,CACxC,OAAO,EAAE;IACP,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,WAAW,CAAC,EAAE,OAAO,CAAC;CACvB,GACA,OAAO,CAAC;IAAE,MAAM,EAAE,SAAS,CAAC;IAAC,GAAG,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,OAAO,iBAAiB,EAAE,IAAI,CAAC;IAAC,OAAO,EAAE,WAAW,CAAA;CAAE,GAAG,IAAI,CAAC,CAoBhH;AAED;;;GAGG;AACH,wBAAgB,eAAe,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/D,IAAI,EAAE,CAAC,GAAG,CAAC,EAAE,EACb,MAAM,EAAE,YAAY,EACpB,GAAG,EAAE,MAAM,GACV,IAAI,CAyBN;AA2CD,OAAO,EAAE,YAAY,EAAE,KAAK,SAAS,EAAE,KAAK,MAAM,EAAE,KAAK,WAAW,EAAE,KAAK,YAAY,EAAE,CAAC"}
package/esm/src/index.js CHANGED
@@ -11,13 +11,15 @@ 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";
18
18
  import { registerAnnouncementsCommand } from "./commands/announcements.js";
19
19
  import { registerCalendarCommand } from "./commands/calendar.js";
20
20
  import { registerSkillsCommand } from "./commands/skills.js";
21
+ import { registerAssignmentsCommand } from "./commands/assignments.js";
22
+ import { registerUploadCommand } from "./commands/upload.js";
21
23
  const program = new Command();
22
24
  program
23
25
  .name("openape")
@@ -32,7 +34,7 @@ program
32
34
  .option("--silent", "Suppress all log output (JSON only)")
33
35
  .option("--headed", "Run browser in visible mode");
34
36
  // Register subcommands
35
- registerAuthCommand(program);
37
+ registerCommand(program);
36
38
  registerCoursesCommand(program);
37
39
  registerVideosCommand(program);
38
40
  registerQuizzesCommand(program);
@@ -42,6 +44,8 @@ registerForumsCommand(program);
42
44
  registerAnnouncementsCommand(program);
43
45
  registerCalendarCommand(program);
44
46
  registerSkillsCommand(program);
47
+ registerAssignmentsCommand(program);
48
+ registerUploadCommand(program);
45
49
  /**
46
50
  * Load configuration and authenticate, returning the context for commands.
47
51
  */
@@ -84,7 +88,14 @@ export async function createSessionContext(options) {
84
88
  */
85
89
  export function formatAndOutput(data, format, log) {
86
90
  if (format === "json") {
87
- console.log(JSON.stringify(data));
91
+ if (Array.isArray(data)) {
92
+ for (const item of data) {
93
+ console.log(JSON.stringify(item));
94
+ }
95
+ }
96
+ else {
97
+ console.log(JSON.stringify(data));
98
+ }
88
99
  // Exit immediately for AI agent - no need to wait for browser cleanup
89
100
  process.exit(0);
90
101
  }
@@ -1,5 +1,5 @@
1
1
  import { type Browser, type BrowserContext, type Page } from "playwright-core";
2
- import type { AppConfig, Logger } from "./types.js";
2
+ import type { AppConfig, Logger, OutputFormat } from "./types.js";
3
3
  /**
4
4
  * Find a Chromium-based browser executable on Windows.
5
5
  * Priority: Edge → Chrome → Brave
@@ -20,6 +20,28 @@ export declare function launchAuthenticated(config: AppConfig, log: Logger): Pro
20
20
  * Safely close browser and context with timeout.
21
21
  * Designed for AI agent usage - no human interaction needed.
22
22
  * If noWait is true, initiates cleanup but doesn't wait for completion.
23
+ *
24
+ * Note: Closes sequentially (context first, then browser) to avoid libuv
25
+ * assertion failures on Windows when handles are closed concurrently.
23
26
  */
24
27
  export declare function closeBrowserSafely(browser: Browser, context?: BrowserContext, timeoutMs?: number, noWait?: boolean): Promise<void>;
28
+ /**
29
+ * Create API context for WS token operations (no browser required).
30
+ * Returns null if session is invalid or WS token is missing.
31
+ */
32
+ export declare function createApiContext(options: {
33
+ verbose?: boolean;
34
+ headed?: boolean;
35
+ }, command?: {
36
+ optsWithGlobals(): {
37
+ output?: OutputFormat;
38
+ verbose?: boolean;
39
+ };
40
+ }): Promise<{
41
+ log: Logger;
42
+ session: {
43
+ wsToken: string;
44
+ moodleBaseUrl: string;
45
+ };
46
+ } | null>;
25
47
  //# sourceMappingURL=auth.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../../../src/src/lib/auth.ts"],"names":[],"mappings":"AAEA,OAAO,EAAY,KAAK,OAAO,EAAE,KAAK,cAAc,EAAE,KAAK,IAAI,EAAE,MAAM,iBAAiB,CAAC;AACzF,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,EAAE,MAAM,YAAY,CAAC;AAGpD;;;GAGG;AACH,wBAAgB,YAAY,IAAI,MAAM,CAuBrC;AAED;;;;GAIG;AACH,wBAAsB,mBAAmB,CACvC,MAAM,EAAE,SAAS,EACjB,GAAG,EAAE,MAAM,GACV,OAAO,CAAC;IAAE,OAAO,EAAE,OAAO,CAAC;IAAC,OAAO,EAAE,cAAc,CAAC;IAAC,IAAI,EAAE,IAAI,CAAC;IAAC,OAAO,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC,CA4DtF;AAED;;;;GAIG;AACH,wBAAsB,kBAAkB,CACtC,OAAO,EAAE,OAAO,EAChB,OAAO,CAAC,EAAE,cAAc,EACxB,SAAS,GAAE,MAAa,EACxB,MAAM,GAAE,OAAe,GACtB,OAAO,CAAC,IAAI,CAAC,CA4Bf"}
1
+ {"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../../../src/src/lib/auth.ts"],"names":[],"mappings":"AAEA,OAAO,EAAY,KAAK,OAAO,EAAE,KAAK,cAAc,EAAE,KAAK,IAAI,EAAE,MAAM,iBAAiB,CAAC;AACzF,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAGlE;;;GAGG;AACH,wBAAgB,YAAY,IAAI,MAAM,CAuBrC;AAED;;;;GAIG;AACH,wBAAsB,mBAAmB,CACvC,MAAM,EAAE,SAAS,EACjB,GAAG,EAAE,MAAM,GACV,OAAO,CAAC;IAAE,OAAO,EAAE,OAAO,CAAC;IAAC,OAAO,EAAE,cAAc,CAAC;IAAC,IAAI,EAAE,IAAI,CAAC;IAAC,OAAO,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC,CA4DtF;AAED;;;;;;;GAOG;AACH,wBAAsB,kBAAkB,CACtC,OAAO,EAAE,OAAO,EAChB,OAAO,CAAC,EAAE,cAAc,EACxB,SAAS,GAAE,MAAa,EACxB,MAAM,GAAE,OAAe,GACtB,OAAO,CAAC,IAAI,CAAC,CA+Bf;AAoHD;;;GAGG;AACH,wBAAsB,gBAAgB,CACpC,OAAO,EAAE;IAAE,OAAO,CAAC,EAAE,OAAO,CAAC;IAAC,MAAM,CAAC,EAAE,OAAO,CAAA;CAAE,EAChD,OAAO,CAAC,EAAE;IAAE,eAAe,IAAI;QAAE,MAAM,CAAC,EAAE,YAAY,CAAC;QAAC,OAAO,CAAC,EAAE,OAAO,CAAA;KAAE,CAAA;CAAE,GAC5E,OAAO,CAAC;IACT,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,EAAE;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,aAAa,EAAE,MAAM,CAAA;KAAE,CAAC;CACrD,GAAG,IAAI,CAAC,CA0BR"}
@@ -88,17 +88,20 @@ export async function launchAuthenticated(config, log) {
88
88
  * Safely close browser and context with timeout.
89
89
  * Designed for AI agent usage - no human interaction needed.
90
90
  * If noWait is true, initiates cleanup but doesn't wait for completion.
91
+ *
92
+ * Note: Closes sequentially (context first, then browser) to avoid libuv
93
+ * assertion failures on Windows when handles are closed concurrently.
91
94
  */
92
95
  export async function closeBrowserSafely(browser, context, timeoutMs = 5000, noWait = false) {
93
96
  const closePromises = [];
94
- // Close context with error handling
97
+ // Close context first with error handling
95
98
  if (context) {
96
99
  closePromises.push(Promise.race([
97
100
  context.close().catch(() => { }),
98
101
  new Promise(resolve => setTimeout(() => resolve(), timeoutMs))
99
102
  ]));
100
103
  }
101
- // Close browser with error handling
104
+ // Close browser after context with error handling
102
105
  closePromises.push(Promise.race([
103
106
  browser.close().catch(() => { }),
104
107
  new Promise(resolve => setTimeout(() => resolve(), timeoutMs))
@@ -108,7 +111,10 @@ export async function closeBrowserSafely(browser, context, timeoutMs = 5000, noW
108
111
  Promise.allSettled(closePromises);
109
112
  return;
110
113
  }
111
- await Promise.allSettled(closePromises);
114
+ // Wait sequentially to avoid libuv issues on Windows
115
+ for (const promise of closePromises) {
116
+ await promise.catch(() => { });
117
+ }
112
118
  }
113
119
  /**
114
120
  * Attempt to restore a session from stored state.
@@ -192,3 +198,30 @@ async function login(page, config, log) {
192
198
  }
193
199
  log.success("Login completed successfully.");
194
200
  }
201
+ /**
202
+ * Create API context for WS token operations (no browser required).
203
+ * Returns null if session is invalid or WS token is missing.
204
+ */
205
+ export async function createApiContext(options, command) {
206
+ const { createLogger } = await import("./logger.js");
207
+ const { loadWsToken } = await import("./token.js");
208
+ const { getOutputFormat, getSessionPath } = await import("./utils.js");
209
+ const opts = command?.optsWithGlobals ? command.optsWithGlobals() : options;
210
+ const outputFormat = command ? getOutputFormat(command) : "json";
211
+ const silent = outputFormat === "json" && !opts.verbose;
212
+ const log = createLogger(opts.verbose, silent);
213
+ const sessionPath = getSessionPath();
214
+ // Try to load WS token
215
+ const wsToken = loadWsToken(sessionPath);
216
+ if (!wsToken) {
217
+ log.error("未找到 WS token。請先執行 'openape auth login' 進行登入。");
218
+ return null;
219
+ }
220
+ return {
221
+ log,
222
+ session: {
223
+ wsToken,
224
+ moodleBaseUrl: "https://ilearning.cycu.edu.tw",
225
+ },
226
+ };
227
+ }