@mo7yw4ng/openape 1.0.0
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.
- package/LICENSE +21 -0
- package/README.md +97 -0
- package/esm/_dnt.polyfills.d.ts +24 -0
- package/esm/_dnt.polyfills.js +1 -0
- package/esm/_dnt.shims.d.ts +5 -0
- package/esm/_dnt.shims.js +61 -0
- package/esm/deno.d.ts +23 -0
- package/esm/deno.js +22 -0
- package/esm/package.json +3 -0
- package/package.json +34 -0
- package/script/_dnt.polyfills.d.ts +24 -0
- package/script/_dnt.polyfills.js +2 -0
- package/script/_dnt.shims.d.ts +5 -0
- package/script/_dnt.shims.js +65 -0
- package/script/deno.d.ts +23 -0
- package/script/deno.js +24 -0
- package/script/package.json +3 -0
- package/script/src/commands/announcements.d.ts +2 -0
- package/script/src/commands/announcements.js +288 -0
- package/script/src/commands/auth.d.ts +2 -0
- package/script/src/commands/auth.js +238 -0
- package/script/src/commands/calendar.d.ts +2 -0
- package/script/src/commands/calendar.js +375 -0
- package/script/src/commands/courses.d.ts +2 -0
- package/script/src/commands/courses.js +484 -0
- package/script/src/commands/forums.d.ts +2 -0
- package/script/src/commands/forums.js +403 -0
- package/script/src/commands/grades.d.ts +2 -0
- package/script/src/commands/grades.js +259 -0
- package/script/src/commands/materials.d.ts +2 -0
- package/script/src/commands/materials.js +423 -0
- package/script/src/commands/quizzes.d.ts +2 -0
- package/script/src/commands/quizzes.js +228 -0
- package/script/src/commands/skills.d.ts +2 -0
- package/script/src/commands/skills.js +117 -0
- package/script/src/commands/videos.d.ts +2 -0
- package/script/src/commands/videos.js +334 -0
- package/script/src/index.d.ts +26 -0
- package/script/src/index.js +156 -0
- package/script/src/lib/auth.d.ts +24 -0
- package/script/src/lib/auth.js +203 -0
- package/script/src/lib/config.d.ts +5 -0
- package/script/src/lib/config.js +43 -0
- package/script/src/lib/logger.d.ts +2 -0
- package/script/src/lib/logger.js +28 -0
- package/script/src/lib/moodle.d.ts +234 -0
- package/script/src/lib/moodle.js +966 -0
- package/script/src/lib/session.d.ts +7 -0
- package/script/src/lib/session.js +71 -0
- package/script/src/lib/token.d.ts +27 -0
- package/script/src/lib/token.js +154 -0
- package/script/src/lib/types.d.ts +261 -0
- package/script/src/lib/types.js +2 -0
- package/script/src/lib/utils.d.ts +5 -0
- package/script/src/lib/utils.js +43 -0
- package/skills/openape/SKILL.md +328 -0
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.registerMaterialsCommand = void 0;
|
|
7
|
+
const utils_js_1 = require("../lib/utils.js");
|
|
8
|
+
const moodle_js_1 = require("../lib/moodle.js");
|
|
9
|
+
const logger_js_1 = require("../lib/logger.js");
|
|
10
|
+
const auth_js_1 = require("../lib/auth.js");
|
|
11
|
+
const session_js_1 = require("../lib/session.js");
|
|
12
|
+
const auth_js_2 = require("../lib/auth.js");
|
|
13
|
+
const token_js_1 = require("../lib/token.js");
|
|
14
|
+
const path_1 = __importDefault(require("path"));
|
|
15
|
+
const fs_1 = __importDefault(require("fs"));
|
|
16
|
+
function registerMaterialsCommand(program) {
|
|
17
|
+
const materialsCmd = program.command("materials");
|
|
18
|
+
materialsCmd.description("Material/resource operations");
|
|
19
|
+
// Helper to get output format from global or local options
|
|
20
|
+
function getOutputFormat(command) {
|
|
21
|
+
const opts = command.optsWithGlobals();
|
|
22
|
+
return opts.output || "json";
|
|
23
|
+
}
|
|
24
|
+
// Pure API context - no browser required (fast!)
|
|
25
|
+
async function createApiContext(options, command) {
|
|
26
|
+
const opts = command?.optsWithGlobals ? command.optsWithGlobals() : options;
|
|
27
|
+
const outputFormat = getOutputFormat(command || { optsWithGlobals: () => ({ output: "json" }) });
|
|
28
|
+
const silent = outputFormat === "json" && !opts.verbose;
|
|
29
|
+
const log = (0, logger_js_1.createLogger)(opts.verbose, silent);
|
|
30
|
+
const baseDir = (0, utils_js_1.getBaseDir)();
|
|
31
|
+
const sessionPath = path_1.default.resolve(baseDir, ".auth", "storage-state.json");
|
|
32
|
+
// Check if session exists
|
|
33
|
+
if (!fs_1.default.existsSync(sessionPath)) {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
// Try to load WS token
|
|
37
|
+
const wsToken = (0, token_js_1.loadWsToken)(sessionPath);
|
|
38
|
+
if (!wsToken) {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
return {
|
|
42
|
+
log,
|
|
43
|
+
session: {
|
|
44
|
+
wsToken,
|
|
45
|
+
moodleBaseUrl: "https://ilearning.cycu.edu.tw",
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
// Helper function to create session context
|
|
50
|
+
async function createSessionContext(options, command) {
|
|
51
|
+
// Get global options if command is provided (for --verbose, --silent flags)
|
|
52
|
+
const opts = command?.optsWithGlobals ? command.optsWithGlobals() : options;
|
|
53
|
+
// Auto-enable silent mode for JSON output (unless --verbose is also set)
|
|
54
|
+
const outputFormat = getOutputFormat(command || { optsWithGlobals: () => ({ output: "json" }) });
|
|
55
|
+
const silent = outputFormat === "json" && !opts.verbose;
|
|
56
|
+
const log = (0, logger_js_1.createLogger)(opts.verbose, silent);
|
|
57
|
+
// Determine session path
|
|
58
|
+
const baseDir = (0, utils_js_1.getBaseDir)();
|
|
59
|
+
const sessionPath = path_1.default.resolve(baseDir, ".auth", "storage-state.json");
|
|
60
|
+
// Check if session exists
|
|
61
|
+
if (!fs_1.default.existsSync(sessionPath)) {
|
|
62
|
+
log.error("未找到登入 session。請先執行 'openape auth login' 進行登入。");
|
|
63
|
+
log.info(`Session 預期位置: ${sessionPath}`);
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
// Create minimal config
|
|
67
|
+
const config = {
|
|
68
|
+
username: "",
|
|
69
|
+
password: "",
|
|
70
|
+
courseUrl: "",
|
|
71
|
+
moodleBaseUrl: "https://ilearning.cycu.edu.tw",
|
|
72
|
+
headless: !options.headed,
|
|
73
|
+
slowMo: 0,
|
|
74
|
+
authStatePath: sessionPath,
|
|
75
|
+
ollamaBaseUrl: "",
|
|
76
|
+
};
|
|
77
|
+
log.info("啟動瀏覽器...");
|
|
78
|
+
const { browser, context, page } = await (0, auth_js_1.launchAuthenticated)(config, log);
|
|
79
|
+
try {
|
|
80
|
+
const session = await (0, session_js_1.extractSessionInfo)(page, config, log);
|
|
81
|
+
return { log, page, session, browser, context };
|
|
82
|
+
}
|
|
83
|
+
catch (err) {
|
|
84
|
+
await context.close();
|
|
85
|
+
await browser.close();
|
|
86
|
+
throw err;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
// Helper to sanitize filenames
|
|
90
|
+
function sanitizeFilename(name) {
|
|
91
|
+
// Remove or replace invalid characters
|
|
92
|
+
return name.replace(/[<>:"/\\|?*]/g, "_").replace(/\s+/g, "_");
|
|
93
|
+
}
|
|
94
|
+
// Helper to download a single resource
|
|
95
|
+
async function downloadResource(page, resource, outputDir, log) {
|
|
96
|
+
try {
|
|
97
|
+
// Only download resource type (skip url)
|
|
98
|
+
if (resource.modType !== "resource") {
|
|
99
|
+
log.debug(` Skipping ${resource.modType}: ${resource.name}`);
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
// Create course directory
|
|
103
|
+
const courseDir = path_1.default.join(outputDir, sanitizeFilename(resource.course_name));
|
|
104
|
+
if (!fs_1.default.existsSync(courseDir)) {
|
|
105
|
+
fs_1.default.mkdirSync(courseDir, { recursive: true });
|
|
106
|
+
}
|
|
107
|
+
// Navigate to resource page
|
|
108
|
+
log.debug(` Downloading: ${resource.name}`);
|
|
109
|
+
await page.goto(resource.url, { waitUntil: "domcontentloaded", timeout: 30000 });
|
|
110
|
+
// Try to find download link on the page
|
|
111
|
+
const downloadLinks = await page.$$eval('a[href*="forcedownload=1"]', (links) => links.map((a) => a.href));
|
|
112
|
+
if (downloadLinks.length === 0) {
|
|
113
|
+
log.warn(` No download link found for: ${resource.name}`);
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
// Download the first available file
|
|
117
|
+
const downloadUrl = downloadLinks[0];
|
|
118
|
+
// Extract filename from URL or use resource name
|
|
119
|
+
const urlObj = new URL(downloadUrl);
|
|
120
|
+
const filenameParam = urlObj.searchParams.get("filename");
|
|
121
|
+
let filename = filenameParam || sanitizeFilename(resource.name);
|
|
122
|
+
// Add extension if missing
|
|
123
|
+
if (resource.mimetype && !path_1.default.extname(filename)) {
|
|
124
|
+
const extMap = {
|
|
125
|
+
"application/pdf": ".pdf",
|
|
126
|
+
"application/vnd.ms-powerpoint": ".ppt",
|
|
127
|
+
"application/vnd.openxmlformats-officedocument.presentationml.presentation": ".pptx",
|
|
128
|
+
"application/msword": ".doc",
|
|
129
|
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx",
|
|
130
|
+
"application/vnd.ms-excel": ".xls",
|
|
131
|
+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx",
|
|
132
|
+
"application/zip": ".zip",
|
|
133
|
+
"image/jpeg": ".jpg",
|
|
134
|
+
"image/png": ".png",
|
|
135
|
+
};
|
|
136
|
+
if (extMap[resource.mimetype]) {
|
|
137
|
+
filename += extMap[resource.mimetype];
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
const outputPath = path_1.default.join(courseDir, filename);
|
|
141
|
+
// Trigger download
|
|
142
|
+
const downloadPromise = page.waitForEvent("download", { timeout: 30000 });
|
|
143
|
+
await page.goto(downloadUrl, { waitUntil: "domcontentloaded" });
|
|
144
|
+
const download = await downloadPromise;
|
|
145
|
+
// Save file
|
|
146
|
+
await download.saveAs(outputPath);
|
|
147
|
+
const stats = fs_1.default.statSync(outputPath);
|
|
148
|
+
log.success(` Downloaded: ${filename} (${(stats.size / 1024).toFixed(1)} KB)`);
|
|
149
|
+
return {
|
|
150
|
+
filename,
|
|
151
|
+
path: outputPath,
|
|
152
|
+
size: stats.size,
|
|
153
|
+
course_id: resource.course_id,
|
|
154
|
+
course_name: resource.course_name,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
catch (err) {
|
|
158
|
+
log.warn(` Failed to download ${resource.name}: ${err instanceof Error ? err.message : String(err)}`);
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
materialsCmd
|
|
163
|
+
.command("list-all")
|
|
164
|
+
.description("List all materials/resources across all courses")
|
|
165
|
+
.option("--level <type>", "Course level: in_progress (default) | all", "in_progress")
|
|
166
|
+
.option("--output <format>", "Output format: json|csv|table|silent")
|
|
167
|
+
.action(async (options, command) => {
|
|
168
|
+
// Try pure API mode (no browser, fast!)
|
|
169
|
+
const apiContext = await createApiContext(options, command);
|
|
170
|
+
if (apiContext) {
|
|
171
|
+
try {
|
|
172
|
+
const classification = options.level === "all" ? undefined : "inprogress";
|
|
173
|
+
const courses = await (0, moodle_js_1.getEnrolledCoursesApi)(apiContext.session, {
|
|
174
|
+
classification,
|
|
175
|
+
});
|
|
176
|
+
// Get materials via WS API (no browser needed!)
|
|
177
|
+
const courseIds = courses.map(c => c.id);
|
|
178
|
+
const apiResources = await (0, moodle_js_1.getResourcesByCoursesApi)(apiContext.session, courseIds);
|
|
179
|
+
// Build a map of courseId -> course for quick lookup
|
|
180
|
+
const courseMap = new Map(courses.map(c => [c.id, c]));
|
|
181
|
+
const allMaterials = [];
|
|
182
|
+
for (const resource of apiResources) {
|
|
183
|
+
const course = courseMap.get(resource.courseId);
|
|
184
|
+
if (course) {
|
|
185
|
+
allMaterials.push({
|
|
186
|
+
course_id: resource.courseId,
|
|
187
|
+
course_name: course.fullname,
|
|
188
|
+
cmid: resource.cmid,
|
|
189
|
+
name: resource.name,
|
|
190
|
+
url: resource.url,
|
|
191
|
+
modType: resource.modType,
|
|
192
|
+
mimetype: resource.mimetype,
|
|
193
|
+
filesize: resource.filesize,
|
|
194
|
+
modified: resource.modified,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
const output = {
|
|
199
|
+
status: "success",
|
|
200
|
+
timestamp: new Date().toISOString(),
|
|
201
|
+
level: options.level,
|
|
202
|
+
materials: allMaterials.map(m => ({
|
|
203
|
+
course_id: m.course_id,
|
|
204
|
+
course_name: m.course_name,
|
|
205
|
+
id: m.cmid,
|
|
206
|
+
name: m.name,
|
|
207
|
+
type: m.modType,
|
|
208
|
+
mimetype: m.mimetype,
|
|
209
|
+
filesize: m.filesize,
|
|
210
|
+
modified: m.modified ? new Date(m.modified * 1000).toISOString() : null,
|
|
211
|
+
url: m.url,
|
|
212
|
+
})),
|
|
213
|
+
summary: {
|
|
214
|
+
total_courses: courses.length,
|
|
215
|
+
total_materials: allMaterials.length,
|
|
216
|
+
by_type: allMaterials.reduce((acc, m) => {
|
|
217
|
+
acc[m.modType] = (acc[m.modType] || 0) + 1;
|
|
218
|
+
return acc;
|
|
219
|
+
}, {}),
|
|
220
|
+
},
|
|
221
|
+
};
|
|
222
|
+
console.log(JSON.stringify(output));
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
catch (e) {
|
|
226
|
+
// API failed, fall through to browser mode
|
|
227
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
228
|
+
console.error(`// API mode failed: ${msg}, trying browser mode...`);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
// Fallback to browser mode
|
|
232
|
+
const context = await createSessionContext(options, command);
|
|
233
|
+
if (!context) {
|
|
234
|
+
process.exitCode = 1;
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
const { log, page, session, browser, context: browserContext } = context;
|
|
238
|
+
try {
|
|
239
|
+
// Map level to classification
|
|
240
|
+
const classification = options.level === "all" ? undefined : "inprogress";
|
|
241
|
+
const courses = await (0, moodle_js_1.getEnrolledCourses)(page, session, log, { classification });
|
|
242
|
+
const allMaterials = [];
|
|
243
|
+
for (const course of courses) {
|
|
244
|
+
const resources = await (0, moodle_js_1.getResourcesInCourse)(page, session, course.id, log);
|
|
245
|
+
for (const resource of resources) {
|
|
246
|
+
allMaterials.push({
|
|
247
|
+
course_id: course.id,
|
|
248
|
+
course_name: course.fullname,
|
|
249
|
+
cmid: resource.cmid,
|
|
250
|
+
name: resource.name,
|
|
251
|
+
url: resource.url,
|
|
252
|
+
modType: resource.modType,
|
|
253
|
+
mimetype: resource.mimetype,
|
|
254
|
+
filesize: resource.filesize,
|
|
255
|
+
modified: resource.modified,
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
const output = {
|
|
260
|
+
status: "success",
|
|
261
|
+
timestamp: new Date().toISOString(),
|
|
262
|
+
level: options.level,
|
|
263
|
+
materials: allMaterials.map(m => ({
|
|
264
|
+
course_id: m.course_id,
|
|
265
|
+
course_name: m.course_name,
|
|
266
|
+
id: m.cmid,
|
|
267
|
+
name: m.name,
|
|
268
|
+
type: m.modType,
|
|
269
|
+
mimetype: m.mimetype,
|
|
270
|
+
filesize: m.filesize,
|
|
271
|
+
modified: m.modified ? new Date(m.modified * 1000).toISOString() : null,
|
|
272
|
+
url: m.url,
|
|
273
|
+
})),
|
|
274
|
+
summary: {
|
|
275
|
+
total_courses: courses.length,
|
|
276
|
+
total_materials: allMaterials.length,
|
|
277
|
+
by_type: allMaterials.reduce((acc, m) => {
|
|
278
|
+
acc[m.modType] = (acc[m.modType] || 0) + 1;
|
|
279
|
+
return acc;
|
|
280
|
+
}, {}),
|
|
281
|
+
},
|
|
282
|
+
};
|
|
283
|
+
console.log(JSON.stringify(output));
|
|
284
|
+
}
|
|
285
|
+
finally {
|
|
286
|
+
await (0, auth_js_2.closeBrowserSafely)(browser, browserContext);
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
materialsCmd
|
|
290
|
+
.command("download")
|
|
291
|
+
.description("Download all materials from a specific course")
|
|
292
|
+
.argument("<course-id>", "Course ID")
|
|
293
|
+
.option("--output-dir <path>", "Output directory", "./downloads")
|
|
294
|
+
.option("--output <format>", "Output format: json|csv|table|silent")
|
|
295
|
+
.action(async (courseId, 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 courses = await (0, moodle_js_1.getEnrolledCourses)(page, session, log);
|
|
304
|
+
const course = courses.find(c => c.id === parseInt(courseId, 10));
|
|
305
|
+
if (!course) {
|
|
306
|
+
log.error(`Course not found: ${courseId}`);
|
|
307
|
+
process.exitCode = 1;
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
const resources = await (0, moodle_js_1.getResourcesInCourse)(page, session, course.id, log);
|
|
311
|
+
const materials = resources.map(r => ({
|
|
312
|
+
course_id: course.id,
|
|
313
|
+
course_name: course.fullname,
|
|
314
|
+
cmid: r.cmid,
|
|
315
|
+
name: r.name,
|
|
316
|
+
url: r.url,
|
|
317
|
+
modType: r.modType,
|
|
318
|
+
mimetype: r.mimetype,
|
|
319
|
+
filesize: r.filesize,
|
|
320
|
+
modified: r.modified,
|
|
321
|
+
}));
|
|
322
|
+
log.info(`Found ${materials.length} materials in course: ${course.fullname}`);
|
|
323
|
+
const downloadedFiles = [];
|
|
324
|
+
for (const material of materials) {
|
|
325
|
+
const result = await downloadResource(page, material, options.outputDir, log);
|
|
326
|
+
if (result) {
|
|
327
|
+
downloadedFiles.push(result);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
const summary = {
|
|
331
|
+
total_materials: materials.length,
|
|
332
|
+
downloaded: downloadedFiles.length,
|
|
333
|
+
skipped: materials.length - downloadedFiles.length,
|
|
334
|
+
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 (0, auth_js_2.closeBrowserSafely)(browser, browserContext);
|
|
352
|
+
}
|
|
353
|
+
});
|
|
354
|
+
materialsCmd
|
|
355
|
+
.command("download-all")
|
|
356
|
+
.description("Download all materials from all courses")
|
|
357
|
+
.option("--output-dir <path>", "Output directory", "./downloads")
|
|
358
|
+
.option("--level <type>", "Course level: in_progress (default) | all", "in_progress")
|
|
359
|
+
.option("--output <format>", "Output format: json|csv|table|silent")
|
|
360
|
+
.action(async (options, command) => {
|
|
361
|
+
const context = await createSessionContext(options, command);
|
|
362
|
+
if (!context) {
|
|
363
|
+
process.exitCode = 1;
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
const { log, page, session, browser, context: browserContext } = context;
|
|
367
|
+
try {
|
|
368
|
+
// Map level to classification
|
|
369
|
+
const classification = options.level === "all" ? undefined : "inprogress";
|
|
370
|
+
const courses = await (0, moodle_js_1.getEnrolledCourses)(page, session, log, { classification });
|
|
371
|
+
log.info(`Scanning ${courses.length} courses for materials...`);
|
|
372
|
+
const allMaterials = [];
|
|
373
|
+
for (const course of courses) {
|
|
374
|
+
const resources = await (0, moodle_js_1.getResourcesInCourse)(page, session, course.id, log);
|
|
375
|
+
for (const resource of resources) {
|
|
376
|
+
allMaterials.push({
|
|
377
|
+
course_id: course.id,
|
|
378
|
+
course_name: course.fullname,
|
|
379
|
+
cmid: resource.cmid,
|
|
380
|
+
name: resource.name,
|
|
381
|
+
url: resource.url,
|
|
382
|
+
modType: resource.modType,
|
|
383
|
+
mimetype: resource.mimetype,
|
|
384
|
+
filesize: resource.filesize,
|
|
385
|
+
modified: resource.modified,
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
log.info(`Found ${allMaterials.length} materials across ${courses.length} courses`);
|
|
390
|
+
const downloadedFiles = [];
|
|
391
|
+
for (const material of allMaterials) {
|
|
392
|
+
const result = await downloadResource(page, material, options.outputDir, log);
|
|
393
|
+
if (result) {
|
|
394
|
+
downloadedFiles.push(result);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
const summary = {
|
|
398
|
+
total_courses: courses.length,
|
|
399
|
+
total_materials: allMaterials.length,
|
|
400
|
+
downloaded: downloadedFiles.length,
|
|
401
|
+
skipped: allMaterials.length - downloadedFiles.length,
|
|
402
|
+
total_size: downloadedFiles.reduce((sum, f) => sum + f.size, 0),
|
|
403
|
+
};
|
|
404
|
+
const output = {
|
|
405
|
+
status: "success",
|
|
406
|
+
timestamp: new Date().toISOString(),
|
|
407
|
+
downloaded_files: downloadedFiles.map(f => ({
|
|
408
|
+
filename: f.filename,
|
|
409
|
+
path: f.path,
|
|
410
|
+
size: f.size,
|
|
411
|
+
course_id: f.course_id,
|
|
412
|
+
course_name: f.course_name,
|
|
413
|
+
})),
|
|
414
|
+
summary,
|
|
415
|
+
};
|
|
416
|
+
console.log(JSON.stringify(output));
|
|
417
|
+
}
|
|
418
|
+
finally {
|
|
419
|
+
await (0, auth_js_2.closeBrowserSafely)(browser, browserContext);
|
|
420
|
+
}
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
exports.registerMaterialsCommand = registerMaterialsCommand;
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.registerQuizzesCommand = void 0;
|
|
7
|
+
const utils_js_1 = require("../lib/utils.js");
|
|
8
|
+
const moodle_js_1 = require("../lib/moodle.js");
|
|
9
|
+
const logger_js_1 = require("../lib/logger.js");
|
|
10
|
+
const auth_js_1 = require("../lib/auth.js");
|
|
11
|
+
const session_js_1 = require("../lib/session.js");
|
|
12
|
+
const auth_js_2 = require("../lib/auth.js");
|
|
13
|
+
const index_js_1 = require("../index.js");
|
|
14
|
+
const token_js_1 = require("../lib/token.js");
|
|
15
|
+
const path_1 = __importDefault(require("path"));
|
|
16
|
+
const fs_1 = __importDefault(require("fs"));
|
|
17
|
+
function registerQuizzesCommand(program) {
|
|
18
|
+
const quizzesCmd = program.command("quizzes");
|
|
19
|
+
quizzesCmd.description("Quiz operations");
|
|
20
|
+
function getOutputFormat(command) {
|
|
21
|
+
const opts = command.optsWithGlobals();
|
|
22
|
+
return opts.output || "json";
|
|
23
|
+
}
|
|
24
|
+
// Pure API context - no browser required (fast!)
|
|
25
|
+
async function createApiContext(options, command) {
|
|
26
|
+
const opts = command?.optsWithGlobals ? command.optsWithGlobals() : options;
|
|
27
|
+
const outputFormat = getOutputFormat(command || { optsWithGlobals: () => ({ output: "json" }) });
|
|
28
|
+
const silent = outputFormat === "json" && !opts.verbose;
|
|
29
|
+
const log = (0, logger_js_1.createLogger)(opts.verbose, silent);
|
|
30
|
+
const baseDir = (0, utils_js_1.getBaseDir)();
|
|
31
|
+
const sessionPath = path_1.default.resolve(baseDir, ".auth", "storage-state.json");
|
|
32
|
+
// Check if session exists
|
|
33
|
+
if (!fs_1.default.existsSync(sessionPath)) {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
// Try to load WS token
|
|
37
|
+
const wsToken = (0, token_js_1.loadWsToken)(sessionPath);
|
|
38
|
+
if (!wsToken) {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
return {
|
|
42
|
+
log,
|
|
43
|
+
session: {
|
|
44
|
+
wsToken,
|
|
45
|
+
moodleBaseUrl: "https://ilearning.cycu.edu.tw",
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
// Helper function to create session context
|
|
50
|
+
async function createSessionContext(options, command) {
|
|
51
|
+
// Get global options if command is provided (for --verbose, --silent flags)
|
|
52
|
+
const opts = command?.optsWithGlobals ? command.optsWithGlobals() : options;
|
|
53
|
+
// Auto-enable silent mode for JSON output (unless --verbose is also set)
|
|
54
|
+
const outputFormat = getOutputFormat(command || { optsWithGlobals: () => ({ output: "json" }) });
|
|
55
|
+
const silent = outputFormat === "json" && !opts.verbose;
|
|
56
|
+
const log = (0, logger_js_1.createLogger)(opts.verbose, silent);
|
|
57
|
+
const baseDir = (0, utils_js_1.getBaseDir)();
|
|
58
|
+
const sessionPath = path_1.default.resolve(baseDir, ".auth", "storage-state.json");
|
|
59
|
+
if (!fs_1.default.existsSync(sessionPath)) {
|
|
60
|
+
log.error("未找到登入 session。請先執行 'openape auth login' 進行登入。");
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
const config = {
|
|
64
|
+
username: "",
|
|
65
|
+
password: "",
|
|
66
|
+
courseUrl: "",
|
|
67
|
+
moodleBaseUrl: "https://ilearning.cycu.edu.tw",
|
|
68
|
+
headless: !options.headed,
|
|
69
|
+
slowMo: 0,
|
|
70
|
+
authStatePath: sessionPath,
|
|
71
|
+
ollamaBaseUrl: "",
|
|
72
|
+
};
|
|
73
|
+
log.info("啟動瀏覽器...");
|
|
74
|
+
const { browser, context, page } = await (0, auth_js_1.launchAuthenticated)(config, log);
|
|
75
|
+
try {
|
|
76
|
+
const session = await (0, session_js_1.extractSessionInfo)(page, config, log);
|
|
77
|
+
return { log, page, session, browser, context };
|
|
78
|
+
}
|
|
79
|
+
catch (err) {
|
|
80
|
+
await context.close();
|
|
81
|
+
await browser.close();
|
|
82
|
+
throw err;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
quizzesCmd
|
|
86
|
+
.command("list")
|
|
87
|
+
.description("List quizzes in a course")
|
|
88
|
+
.argument("<course-id>", "Course ID")
|
|
89
|
+
.option("--available-only", "Show only available quizzes")
|
|
90
|
+
.option("--output <format>", "Output format: json|csv|table|silent")
|
|
91
|
+
.action(async (courseId, options, command) => {
|
|
92
|
+
const output = getOutputFormat(command);
|
|
93
|
+
// Try pure API mode (no browser, fast!)
|
|
94
|
+
const apiContext = await createApiContext(options, command);
|
|
95
|
+
if (apiContext) {
|
|
96
|
+
try {
|
|
97
|
+
const quizzes = await (0, moodle_js_1.getQuizzesByCoursesApi)(apiContext.session, [parseInt(courseId, 10)]);
|
|
98
|
+
// Filter by available only if requested (API returns all, no completion status)
|
|
99
|
+
// Note: API doesn't provide completion status, so --available-only won't work in API mode
|
|
100
|
+
if (options.availableOnly) {
|
|
101
|
+
apiContext.log.warn("--available-only is not supported in API mode, showing all quizzes");
|
|
102
|
+
}
|
|
103
|
+
(0, index_js_1.formatAndOutput)(quizzes, output, apiContext.log);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
catch (e) {
|
|
107
|
+
// API failed, fall through to browser mode
|
|
108
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
109
|
+
console.error(`// API mode failed: ${msg}, trying browser mode...`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
// Fallback to browser mode
|
|
113
|
+
const context = await createSessionContext(options, command);
|
|
114
|
+
if (!context) {
|
|
115
|
+
process.exitCode = 1;
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
const { log, page, session, browser, context: browserContext } = context;
|
|
119
|
+
try {
|
|
120
|
+
const quizzes = await (0, moodle_js_1.getQuizzesInCourse)(page, session, parseInt(courseId, 10), log);
|
|
121
|
+
let filteredQuizzes = quizzes;
|
|
122
|
+
if (options.availableOnly) {
|
|
123
|
+
filteredQuizzes = quizzes.filter(q => !q.isComplete);
|
|
124
|
+
}
|
|
125
|
+
(0, index_js_1.formatAndOutput)(filteredQuizzes, output, log);
|
|
126
|
+
}
|
|
127
|
+
finally {
|
|
128
|
+
await (0, auth_js_2.closeBrowserSafely)(browser, browserContext);
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
quizzesCmd
|
|
132
|
+
.command("list-all")
|
|
133
|
+
.description("List all available quizzes across all courses")
|
|
134
|
+
.option("--level <type>", "Course level: in_progress (default) | all", "in_progress")
|
|
135
|
+
.option("--output <format>", "Output format: json|csv|table|silent")
|
|
136
|
+
.action(async (options, command) => {
|
|
137
|
+
const output = getOutputFormat(command);
|
|
138
|
+
// Try pure API mode (no browser, fast!)
|
|
139
|
+
const apiContext = await createApiContext(options, command);
|
|
140
|
+
if (apiContext) {
|
|
141
|
+
try {
|
|
142
|
+
const classification = options.level === "all" ? undefined : "inprogress";
|
|
143
|
+
const courses = await (0, moodle_js_1.getEnrolledCoursesApi)(apiContext.session, {
|
|
144
|
+
classification,
|
|
145
|
+
});
|
|
146
|
+
// Get quizzes via WS API (no browser needed!)
|
|
147
|
+
const courseIds = courses.map(c => c.id);
|
|
148
|
+
const apiQuizzes = await (0, moodle_js_1.getQuizzesByCoursesApi)(apiContext.session, courseIds);
|
|
149
|
+
// Build a map of courseId -> course for quick lookup
|
|
150
|
+
const courseMap = new Map(courses.map(c => [c.id, c]));
|
|
151
|
+
const allQuizzes = [];
|
|
152
|
+
for (const q of apiQuizzes) {
|
|
153
|
+
const course = courseMap.get(q.courseId);
|
|
154
|
+
if (course) {
|
|
155
|
+
allQuizzes.push({
|
|
156
|
+
courseName: course.fullname,
|
|
157
|
+
name: q.name,
|
|
158
|
+
url: q.url,
|
|
159
|
+
cmid: q.cmid,
|
|
160
|
+
isComplete: q.isComplete,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
apiContext.log.info(`\n總計發現 ${allQuizzes.length} 個測驗。`);
|
|
165
|
+
(0, index_js_1.formatAndOutput)(allQuizzes, output, apiContext.log);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
catch (e) {
|
|
169
|
+
// API failed, fall through to browser mode
|
|
170
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
171
|
+
console.error(`// API mode failed: ${msg}, trying browser mode...`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
// Fallback to browser mode
|
|
175
|
+
const context = await createSessionContext(options, command);
|
|
176
|
+
if (!context) {
|
|
177
|
+
process.exitCode = 1;
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
const { log, page, session, browser, context: browserContext } = context;
|
|
181
|
+
try {
|
|
182
|
+
const classification = options.level === "all" ? undefined : "inprogress";
|
|
183
|
+
const courses = await (0, moodle_js_1.getEnrolledCourses)(page, session, log, { classification });
|
|
184
|
+
const allQuizzes = [];
|
|
185
|
+
for (const course of courses) {
|
|
186
|
+
const quizzes = await (0, moodle_js_1.getQuizzesInCourse)(page, session, course.id, log);
|
|
187
|
+
for (const q of quizzes) {
|
|
188
|
+
allQuizzes.push({
|
|
189
|
+
courseName: course.fullname,
|
|
190
|
+
name: q.name,
|
|
191
|
+
url: q.url,
|
|
192
|
+
cmid: q.cmid,
|
|
193
|
+
isComplete: q.isComplete,
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
log.info(`\n總計發現 ${allQuizzes.length} 個測驗。`);
|
|
198
|
+
(0, index_js_1.formatAndOutput)(allQuizzes, output, log);
|
|
199
|
+
}
|
|
200
|
+
finally {
|
|
201
|
+
await (0, auth_js_2.closeBrowserSafely)(browser, browserContext);
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
quizzesCmd
|
|
205
|
+
.command("open")
|
|
206
|
+
.description("Open a quiz URL in browser (manual mode)")
|
|
207
|
+
.argument("<quiz-url>", "Quiz URL")
|
|
208
|
+
.option("--headed", "Run browser in visible mode (default: true)")
|
|
209
|
+
.action(async (quizUrl, options, command) => {
|
|
210
|
+
const context = await createSessionContext({ ...options, headed: true }, command);
|
|
211
|
+
if (!context) {
|
|
212
|
+
process.exitCode = 1;
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
const { log, page, browser, context: browserContext } = context;
|
|
216
|
+
try {
|
|
217
|
+
log.info(`導航至測驗頁面: ${quizUrl}`);
|
|
218
|
+
await page.goto(quizUrl, { waitUntil: "domcontentloaded" });
|
|
219
|
+
log.info("瀏覽器已開啟,請手動完成測驗。");
|
|
220
|
+
log.info("按 Ctrl+C 關閉瀏覽器。");
|
|
221
|
+
await new Promise(() => { });
|
|
222
|
+
}
|
|
223
|
+
finally {
|
|
224
|
+
await (0, auth_js_2.closeBrowserSafely)(browser, browserContext);
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
exports.registerQuizzesCommand = registerQuizzesCommand;
|