@mo7yw4ng/openape 1.0.4 → 1.0.6
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/README.md +11 -8
- package/esm/deno.js +1 -1
- package/esm/src/commands/announcements.d.ts.map +1 -1
- package/esm/src/commands/announcements.js +22 -85
- package/esm/src/commands/assignments.d.ts.map +1 -1
- package/esm/src/commands/assignments.js +2 -3
- package/esm/src/commands/auth.d.ts.map +1 -1
- package/esm/src/commands/auth.js +24 -14
- package/esm/src/commands/calendar.d.ts.map +1 -1
- package/esm/src/commands/calendar.js +32 -84
- package/esm/src/commands/courses.d.ts.map +1 -1
- package/esm/src/commands/courses.js +2 -38
- package/esm/src/commands/forums.d.ts.map +1 -1
- package/esm/src/commands/forums.js +47 -175
- package/esm/src/commands/grades.d.ts.map +1 -1
- package/esm/src/commands/grades.js +10 -47
- package/esm/src/commands/materials.d.ts.map +1 -1
- package/esm/src/commands/materials.js +135 -223
- package/esm/src/commands/quizzes.d.ts.map +1 -1
- package/esm/src/commands/quizzes.js +32 -56
- package/esm/src/commands/skills.js +3 -3
- package/esm/src/commands/upload.d.ts.map +1 -1
- package/esm/src/commands/upload.js +2 -5
- package/esm/src/commands/videos.d.ts.map +1 -1
- package/esm/src/commands/videos.js +6 -76
- package/esm/src/index.d.ts +2 -1
- package/esm/src/index.d.ts.map +1 -1
- package/esm/src/index.js +5 -1
- package/esm/src/lib/auth.d.ts +21 -2
- package/esm/src/lib/auth.d.ts.map +1 -1
- package/esm/src/lib/auth.js +79 -20
- package/esm/src/lib/logger.d.ts +2 -2
- package/esm/src/lib/logger.d.ts.map +1 -1
- package/esm/src/lib/logger.js +6 -4
- package/esm/src/lib/moodle.d.ts +18 -0
- package/esm/src/lib/moodle.d.ts.map +1 -1
- package/esm/src/lib/moodle.js +54 -2
- package/esm/src/lib/utils.d.ts +3 -8
- package/esm/src/lib/utils.d.ts.map +1 -1
- package/esm/src/lib/utils.js +3 -10
- package/package.json +1 -1
- package/script/deno.js +1 -1
- package/script/src/commands/announcements.d.ts.map +1 -1
- package/script/src/commands/announcements.js +23 -89
- package/script/src/commands/assignments.d.ts.map +1 -1
- package/script/src/commands/assignments.js +2 -3
- package/script/src/commands/auth.d.ts.map +1 -1
- package/script/src/commands/auth.js +24 -14
- package/script/src/commands/calendar.d.ts.map +1 -1
- package/script/src/commands/calendar.js +33 -85
- package/script/src/commands/courses.d.ts.map +1 -1
- package/script/src/commands/courses.js +9 -48
- package/script/src/commands/forums.d.ts.map +1 -1
- package/script/src/commands/forums.js +50 -181
- package/script/src/commands/grades.d.ts.map +1 -1
- package/script/src/commands/grades.js +14 -54
- package/script/src/commands/materials.d.ts.map +1 -1
- package/script/src/commands/materials.js +132 -220
- package/script/src/commands/quizzes.d.ts.map +1 -1
- package/script/src/commands/quizzes.js +40 -67
- package/script/src/commands/skills.js +3 -3
- package/script/src/commands/upload.d.ts.map +1 -1
- package/script/src/commands/upload.js +2 -5
- package/script/src/commands/videos.d.ts.map +1 -1
- package/script/src/commands/videos.js +11 -81
- package/script/src/index.d.ts +2 -1
- package/script/src/index.d.ts.map +1 -1
- package/script/src/index.js +5 -1
- package/script/src/lib/auth.d.ts +21 -2
- package/script/src/lib/auth.d.ts.map +1 -1
- package/script/src/lib/auth.js +83 -56
- package/script/src/lib/logger.d.ts +2 -2
- package/script/src/lib/logger.d.ts.map +1 -1
- package/script/src/lib/logger.js +6 -4
- package/script/src/lib/moodle.d.ts +18 -0
- package/script/src/lib/moodle.d.ts.map +1 -1
- package/script/src/lib/moodle.js +56 -2
- package/script/src/lib/utils.d.ts +3 -8
- package/script/src/lib/utils.d.ts.map +1 -1
- package/script/src/lib/utils.js +3 -11
- package/skills/openape/SKILL.md +10 -8
|
@@ -1,75 +1,24 @@
|
|
|
1
|
-
import { getOutputFormat, sanitizeFilename,
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
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
|
|
14
|
-
async function
|
|
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
|
-
//
|
|
58
|
-
|
|
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,33 +37,61 @@ export function registerMaterialsCommand(program) {
|
|
|
88
37
|
}
|
|
89
38
|
}
|
|
90
39
|
const outputPath = path.join(courseDir, filename);
|
|
91
|
-
//
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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")
|
|
115
91
|
.option("--level <type>", "Course level: in_progress (default) | all", "in_progress")
|
|
116
92
|
.option("--output <format>", "Output format: json|csv|table|silent")
|
|
117
93
|
.action(async (options, command) => {
|
|
94
|
+
const output = getOutputFormat(command);
|
|
118
95
|
const apiContext = await createApiContext(options, command);
|
|
119
96
|
if (!apiContext) {
|
|
120
97
|
process.exitCode = 1;
|
|
@@ -124,10 +101,8 @@ export function registerMaterialsCommand(program) {
|
|
|
124
101
|
const courses = await getEnrolledCoursesApi(apiContext.session, {
|
|
125
102
|
classification,
|
|
126
103
|
});
|
|
127
|
-
// Get materials via WS API (no browser needed!)
|
|
128
104
|
const courseIds = courses.map(c => c.id);
|
|
129
105
|
const apiResources = await getResourcesByCoursesApi(apiContext.session, courseIds);
|
|
130
|
-
// Build a map of courseId -> course for quick lookup
|
|
131
106
|
const courseMap = new Map(courses.map(c => [c.id, c]));
|
|
132
107
|
const allMaterials = [];
|
|
133
108
|
for (const resource of apiResources) {
|
|
@@ -146,178 +121,115 @@ export function registerMaterialsCommand(program) {
|
|
|
146
121
|
});
|
|
147
122
|
}
|
|
148
123
|
}
|
|
149
|
-
const
|
|
124
|
+
const items = allMaterials.map(m => ({
|
|
125
|
+
course_id: m.course_id,
|
|
126
|
+
course_name: m.course_name,
|
|
127
|
+
id: m.cmid,
|
|
128
|
+
name: m.name,
|
|
129
|
+
type: m.modType,
|
|
130
|
+
mimetype: m.mimetype,
|
|
131
|
+
filesize: m.filesize,
|
|
132
|
+
modified: m.modified ? new Date(m.modified * 1000).toISOString() : null,
|
|
133
|
+
url: m.url,
|
|
134
|
+
}));
|
|
135
|
+
formatAndOutput(items, output, apiContext.log, {
|
|
150
136
|
status: "success",
|
|
151
137
|
timestamp: new Date().toISOString(),
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
mimetype: m.mimetype,
|
|
160
|
-
filesize: m.filesize,
|
|
161
|
-
modified: m.modified ? new Date(m.modified * 1000).toISOString() : null,
|
|
162
|
-
url: m.url,
|
|
163
|
-
})),
|
|
164
|
-
summary: {
|
|
165
|
-
total_courses: courses.length,
|
|
166
|
-
total_materials: allMaterials.length,
|
|
167
|
-
by_type: allMaterials.reduce((acc, m) => {
|
|
168
|
-
acc[m.modType] = (acc[m.modType] || 0) + 1;
|
|
169
|
-
return acc;
|
|
170
|
-
}, {}),
|
|
171
|
-
},
|
|
172
|
-
};
|
|
173
|
-
console.log(JSON.stringify(output));
|
|
138
|
+
total_courses: courses.length,
|
|
139
|
+
total_materials: allMaterials.length,
|
|
140
|
+
by_type: allMaterials.reduce((acc, m) => {
|
|
141
|
+
acc[m.modType] = (acc[m.modType] || 0) + 1;
|
|
142
|
+
return acc;
|
|
143
|
+
}, {}),
|
|
144
|
+
});
|
|
174
145
|
});
|
|
175
146
|
materialsCmd
|
|
176
147
|
.command("download")
|
|
177
|
-
.description("Download all materials from a specific course
|
|
148
|
+
.description("Download all materials from a specific course")
|
|
178
149
|
.argument("<course-id>", "Course ID")
|
|
179
150
|
.option("--output-dir <path>", "Output directory", "./downloads")
|
|
180
151
|
.action(async (courseId, options, command) => {
|
|
181
|
-
const
|
|
182
|
-
if (!
|
|
152
|
+
const apiContext = await createApiContext(options, command);
|
|
153
|
+
if (!apiContext) {
|
|
183
154
|
process.exitCode = 1;
|
|
184
155
|
return;
|
|
185
156
|
}
|
|
186
|
-
const { log,
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
}
|
|
225
|
-
}
|
|
226
|
-
const summary = {
|
|
227
|
-
total_materials: materials.length,
|
|
228
|
-
downloaded: downloadedFiles.length,
|
|
229
|
-
skipped: materials.length - downloadedFiles.length,
|
|
230
|
-
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));
|
|
157
|
+
const { log, session } = apiContext;
|
|
158
|
+
const courses = await getEnrolledCoursesApi(session);
|
|
159
|
+
const course = courses.find((c) => c.id === parseInt(courseId, 10));
|
|
160
|
+
if (!course) {
|
|
161
|
+
log.error(`Course not found: ${courseId}`);
|
|
162
|
+
process.exitCode = 1;
|
|
163
|
+
return;
|
|
245
164
|
}
|
|
246
|
-
|
|
247
|
-
|
|
165
|
+
const apiResources = await getResourcesByCoursesApi(session, [course.id]);
|
|
166
|
+
const materials = buildMaterialsList(courses, apiResources);
|
|
167
|
+
log.info(`Found ${materials.length} materials in course: ${course.fullname}`);
|
|
168
|
+
const downloadedFiles = [];
|
|
169
|
+
for (const material of materials) {
|
|
170
|
+
const result = await downloadResourceHttp(material, options.outputDir, log, session.wsToken);
|
|
171
|
+
if (result) {
|
|
172
|
+
downloadedFiles.push(result);
|
|
173
|
+
}
|
|
248
174
|
}
|
|
175
|
+
const items = downloadedFiles.map(f => ({
|
|
176
|
+
filename: f.filename,
|
|
177
|
+
path: f.path,
|
|
178
|
+
size: f.size,
|
|
179
|
+
course_id: f.course_id,
|
|
180
|
+
course_name: f.course_name,
|
|
181
|
+
}));
|
|
182
|
+
formatAndOutput(items, "json", log, {
|
|
183
|
+
status: "success",
|
|
184
|
+
timestamp: new Date().toISOString(),
|
|
185
|
+
total_materials: materials.length,
|
|
186
|
+
downloaded: downloadedFiles.length,
|
|
187
|
+
skipped: materials.length - downloadedFiles.length,
|
|
188
|
+
total_size: downloadedFiles.reduce((sum, f) => sum + f.size, 0),
|
|
189
|
+
});
|
|
249
190
|
});
|
|
250
191
|
materialsCmd
|
|
251
192
|
.command("download-all")
|
|
252
|
-
.description("Download all materials from all courses
|
|
193
|
+
.description("Download all materials from all courses")
|
|
253
194
|
.option("--output-dir <path>", "Output directory", "./downloads")
|
|
254
195
|
.option("--level <type>", "Course level: in_progress (default) | all", "in_progress")
|
|
255
196
|
.action(async (options, command) => {
|
|
256
|
-
const
|
|
257
|
-
if (!
|
|
197
|
+
const apiContext = await createApiContext(options, command);
|
|
198
|
+
if (!apiContext) {
|
|
258
199
|
process.exitCode = 1;
|
|
259
200
|
return;
|
|
260
201
|
}
|
|
261
|
-
const { log,
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
-
}
|
|
202
|
+
const { log, session } = apiContext;
|
|
203
|
+
const classification = options.level === "all" ? undefined : "inprogress";
|
|
204
|
+
const courses = await getEnrolledCoursesApi(session, { classification });
|
|
205
|
+
log.info(`Scanning ${courses.length} courses for materials...`);
|
|
206
|
+
const courseIds = courses.map((c) => c.id);
|
|
207
|
+
const apiResources = await getResourcesByCoursesApi(session, courseIds);
|
|
208
|
+
const allMaterials = buildMaterialsList(courses, apiResources);
|
|
209
|
+
log.info(`Found ${allMaterials.length} materials across ${courses.length} courses`);
|
|
210
|
+
const downloadedFiles = [];
|
|
211
|
+
for (const material of allMaterials) {
|
|
212
|
+
const result = await downloadResourceHttp(material, options.outputDir, log, session.wsToken);
|
|
213
|
+
if (result) {
|
|
214
|
+
downloadedFiles.push(result);
|
|
296
215
|
}
|
|
297
|
-
const summary = {
|
|
298
|
-
total_courses: courses.length,
|
|
299
|
-
total_materials: allMaterials.length,
|
|
300
|
-
downloaded: downloadedFiles.length,
|
|
301
|
-
skipped: allMaterials.length - downloadedFiles.length,
|
|
302
|
-
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
216
|
}
|
|
217
|
+
const items = downloadedFiles.map(f => ({
|
|
218
|
+
filename: f.filename,
|
|
219
|
+
path: f.path,
|
|
220
|
+
size: f.size,
|
|
221
|
+
course_id: f.course_id,
|
|
222
|
+
course_name: f.course_name,
|
|
223
|
+
}));
|
|
224
|
+
formatAndOutput(items, "json", log, {
|
|
225
|
+
status: "success",
|
|
226
|
+
timestamp: new Date().toISOString(),
|
|
227
|
+
total_courses: courses.length,
|
|
228
|
+
total_materials: allMaterials.length,
|
|
229
|
+
downloaded: downloadedFiles.length,
|
|
230
|
+
skipped: allMaterials.length - downloadedFiles.length,
|
|
231
|
+
total_size: downloadedFiles.reduce((sum, f) => sum + f.size, 0),
|
|
232
|
+
});
|
|
321
233
|
});
|
|
322
234
|
materialsCmd
|
|
323
235
|
.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;
|
|
1
|
+
{"version":3,"file":"quizzes.d.ts","sourceRoot":"","sources":["../../../src/src/commands/quizzes.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAsEpC,wBAAgB,sBAAsB,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CA2N7D"}
|
|
@@ -1,10 +1,7 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { getEnrolledCoursesApi, getQuizzesByCoursesApi, startQuizAttemptApi, getQuizAttemptDataApi, processQuizAttemptApi } from "../lib/moodle.js";
|
|
3
|
-
import {
|
|
1
|
+
import { formatTimestamp, getOutputFormat } from "../lib/utils.js";
|
|
2
|
+
import { getEnrolledCoursesApi, getQuizzesByCoursesApi, startQuizAttemptApi, getQuizAttemptDataApi, getAllQuizAttemptDataApi, processQuizAttemptApi } from "../lib/moodle.js";
|
|
3
|
+
import { createApiContext } from "../lib/auth.js";
|
|
4
4
|
import { formatAndOutput } from "../index.js";
|
|
5
|
-
import { loadWsToken } from "../lib/token.js";
|
|
6
|
-
import path from "node:path";
|
|
7
|
-
import fs from "node:fs";
|
|
8
5
|
function stripHtmlKeepLines(html) {
|
|
9
6
|
return html
|
|
10
7
|
.replace(/<br\s*\/?>/gi, "\n")
|
|
@@ -41,41 +38,24 @@ function parseSavedAnswer(html) {
|
|
|
41
38
|
return textMatch[1];
|
|
42
39
|
return null;
|
|
43
40
|
}
|
|
41
|
+
function parseQuizQuestions(questions) {
|
|
42
|
+
return Object.values(questions).map((q) => {
|
|
43
|
+
const parsed = parseQuestionHtml(q.html ?? "");
|
|
44
|
+
const savedAnswer = parseSavedAnswer(q.html ?? "");
|
|
45
|
+
return {
|
|
46
|
+
slot: q.slot,
|
|
47
|
+
type: q.type,
|
|
48
|
+
status: q.status,
|
|
49
|
+
stateclass: q.stateclass,
|
|
50
|
+
savedAnswer,
|
|
51
|
+
question: parsed.text,
|
|
52
|
+
options: parsed.options,
|
|
53
|
+
};
|
|
54
|
+
});
|
|
55
|
+
}
|
|
44
56
|
export function registerQuizzesCommand(program) {
|
|
45
57
|
const quizzesCmd = program.command("quizzes");
|
|
46
58
|
quizzesCmd.description("Quiz operations");
|
|
47
|
-
function getOutputFormat(command) {
|
|
48
|
-
const opts = command.optsWithGlobals();
|
|
49
|
-
return opts.output || "json";
|
|
50
|
-
}
|
|
51
|
-
// Pure API context - no browser required (fast!)
|
|
52
|
-
async function createApiContext(options, command) {
|
|
53
|
-
const opts = command?.optsWithGlobals ? command.optsWithGlobals() : options;
|
|
54
|
-
const outputFormat = getOutputFormat(command || { optsWithGlobals: () => ({ output: "json" }) });
|
|
55
|
-
const silent = outputFormat === "json" && !opts.verbose;
|
|
56
|
-
const log = createLogger(opts.verbose, silent);
|
|
57
|
-
const baseDir = getBaseDir();
|
|
58
|
-
const sessionPath = path.resolve(baseDir, ".auth", "storage-state.json");
|
|
59
|
-
// Check if session exists
|
|
60
|
-
if (!fs.existsSync(sessionPath)) {
|
|
61
|
-
log.error("未找到登入 session。請先執行 'openape auth login' 進行登入。");
|
|
62
|
-
log.info(`Session 預期位置: ${sessionPath}`);
|
|
63
|
-
return null;
|
|
64
|
-
}
|
|
65
|
-
// Try to load WS token
|
|
66
|
-
const wsToken = loadWsToken(sessionPath);
|
|
67
|
-
if (!wsToken) {
|
|
68
|
-
log.error("未找到 WS token。請先執行 'openape auth login' 進行登入。");
|
|
69
|
-
return null;
|
|
70
|
-
}
|
|
71
|
-
return {
|
|
72
|
-
log,
|
|
73
|
-
session: {
|
|
74
|
-
wsToken,
|
|
75
|
-
moodleBaseUrl: "https://ilearning.cycu.edu.tw",
|
|
76
|
-
},
|
|
77
|
-
};
|
|
78
|
-
}
|
|
79
59
|
quizzesCmd
|
|
80
60
|
.command("list")
|
|
81
61
|
.description("List incomplete quizzes in a course")
|
|
@@ -154,8 +134,12 @@ export function registerQuizzesCommand(program) {
|
|
|
154
134
|
}
|
|
155
135
|
try {
|
|
156
136
|
const result = await startQuizAttemptApi(apiContext.session, quizCmid);
|
|
137
|
+
apiContext.log.success(`Quiz attempt ${result.attempt.attemptid} started.`);
|
|
138
|
+
const attemptId = result.attempt.attemptid;
|
|
139
|
+
const data = await getAllQuizAttemptDataApi(apiContext.session, attemptId);
|
|
140
|
+
const questions = parseQuizQuestions(data.questions);
|
|
157
141
|
const outputData = [{
|
|
158
|
-
attemptId
|
|
142
|
+
attemptId,
|
|
159
143
|
quizId: result.attempt.quizid,
|
|
160
144
|
state: result.attempt.state,
|
|
161
145
|
timeStart: formatTimestamp(result.attempt.timestart),
|
|
@@ -163,8 +147,9 @@ export function registerQuizzesCommand(program) {
|
|
|
163
147
|
? formatTimestamp(result.attempt.timefinish)
|
|
164
148
|
: null,
|
|
165
149
|
isPreview: result.attempt.preview,
|
|
150
|
+
totalQuestions: questions.length,
|
|
151
|
+
questions,
|
|
166
152
|
}];
|
|
167
|
-
apiContext.log.success(`Quiz attempt ${result.attempt.attemptid} started.`);
|
|
168
153
|
formatAndOutput(outputData, output, apiContext.log);
|
|
169
154
|
}
|
|
170
155
|
catch (error) {
|
|
@@ -176,7 +161,7 @@ export function registerQuizzesCommand(program) {
|
|
|
176
161
|
.command("info")
|
|
177
162
|
.description("Get quiz attempt data and questions")
|
|
178
163
|
.argument("<attempt-id>", "Quiz attempt ID")
|
|
179
|
-
.option("--page <number>", "Page number", "
|
|
164
|
+
.option("--page <number>", "Page number (-1 for all pages)", "-1")
|
|
180
165
|
.option("--output <format>", "Output format: json|csv|table|silent")
|
|
181
166
|
.action(async (attemptId, options, command) => {
|
|
182
167
|
const output = getOutputFormat(command);
|
|
@@ -186,20 +171,11 @@ export function registerQuizzesCommand(program) {
|
|
|
186
171
|
return;
|
|
187
172
|
}
|
|
188
173
|
try {
|
|
189
|
-
const
|
|
190
|
-
const
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
number: q.questionnumber ?? q.slot,
|
|
195
|
-
type: q.type,
|
|
196
|
-
status: q.status,
|
|
197
|
-
stateclass: q.stateclass,
|
|
198
|
-
savedAnswer,
|
|
199
|
-
question: parsed.text,
|
|
200
|
-
options: parsed.options,
|
|
201
|
-
};
|
|
202
|
-
});
|
|
174
|
+
const pageNumber = parseInt(options.page);
|
|
175
|
+
const data = pageNumber === -1
|
|
176
|
+
? await getAllQuizAttemptDataApi(apiContext.session, parseInt(attemptId))
|
|
177
|
+
: await getQuizAttemptDataApi(apiContext.session, parseInt(attemptId), pageNumber);
|
|
178
|
+
const questions = parseQuizQuestions(data.questions);
|
|
203
179
|
const outputData = [{
|
|
204
180
|
attemptId: data.attempt.attemptid,
|
|
205
181
|
quizId: data.attempt.quizid,
|
|
@@ -240,7 +216,7 @@ export function registerQuizzesCommand(program) {
|
|
|
240
216
|
}
|
|
241
217
|
try {
|
|
242
218
|
// Get attempt data to find uniqueid and sequencecheck values
|
|
243
|
-
const attemptData = await
|
|
219
|
+
const attemptData = await getAllQuizAttemptDataApi(apiContext.session, parseInt(attemptId));
|
|
244
220
|
const uniqueId = attemptData.attempt.uniqueid ?? attemptData.attempt.attemptid;
|
|
245
221
|
const sequenceChecks = new Map();
|
|
246
222
|
for (const q of Object.values(attemptData.questions)) {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import os from "node:os";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
4
5
|
const SKILL_NAME = "openape";
|
|
5
6
|
const GITHUB_RAW_URL = `https://raw.githubusercontent.com/mo7yw4ng/openape/refs/heads/main/skills/${SKILL_NAME}/SKILL.md`;
|
|
6
7
|
/**
|
|
@@ -18,11 +19,10 @@ const PLATFORMS = {
|
|
|
18
19
|
async function readSkillContent() {
|
|
19
20
|
// Try local path first (relative to this file's location)
|
|
20
21
|
try {
|
|
21
|
-
const base = path.dirname(
|
|
22
|
-
const normalized = process.platform === "win32" ? base.replace(/^\//, "") : base;
|
|
22
|
+
const base = path.dirname(fileURLToPath(globalThis[Symbol.for("import-meta-ponyfill-esmodule")](import.meta).url));
|
|
23
23
|
// When running from source: src/commands/ → ../../skills/openape/SKILL.md
|
|
24
24
|
// When bundled by dnt into build/: esm/commands/ or script/ → ../../skills/openape/SKILL.md
|
|
25
|
-
const localPath = path.resolve(
|
|
25
|
+
const localPath = path.resolve(base, "..", "..", "skills", SKILL_NAME, "SKILL.md");
|
|
26
26
|
return await fs.promises.readFile(localPath, "utf-8");
|
|
27
27
|
}
|
|
28
28
|
catch {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"upload.d.ts","sourceRoot":"","sources":["../../../src/src/commands/upload.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAQpC,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,
|
|
1
|
+
{"version":3,"file":"upload.d.ts","sourceRoot":"","sources":["../../../src/src/commands/upload.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAQpC,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CAwD5D"}
|
|
@@ -20,19 +20,16 @@ export function registerUploadCommand(program) {
|
|
|
20
20
|
process.exitCode = 1;
|
|
21
21
|
return;
|
|
22
22
|
}
|
|
23
|
-
// Resolve file path
|
|
24
23
|
const resolvedPath = path.resolve(filePath);
|
|
25
|
-
|
|
24
|
+
let stats;
|
|
26
25
|
try {
|
|
27
|
-
await fs.
|
|
26
|
+
stats = await fs.stat(resolvedPath);
|
|
28
27
|
}
|
|
29
28
|
catch {
|
|
30
29
|
apiContext.log.error(`檔案不存在: ${filePath}`);
|
|
31
30
|
process.exitCode = 1;
|
|
32
31
|
return;
|
|
33
32
|
}
|
|
34
|
-
// Get file size
|
|
35
|
-
const stats = await fs.stat(resolvedPath);
|
|
36
33
|
const fileSizeKB = formatFileSize(stats.size);
|
|
37
34
|
apiContext.log.info(`上傳檔案: ${path.basename(resolvedPath)} (${fileSizeKB} KB)`);
|
|
38
35
|
// Upload file
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"videos.d.ts","sourceRoot":"","sources":["../../../src/src/commands/videos.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;
|
|
1
|
+
{"version":3,"file":"videos.d.ts","sourceRoot":"","sources":["../../../src/src/commands/videos.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAQpC,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CAgT5D"}
|