@mo7yw4ng/openape 1.0.0 → 1.0.2
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 -21
- package/README.md +15 -5
- package/esm/_dnt.polyfills.d.ts +83 -6
- package/esm/_dnt.polyfills.d.ts.map +1 -0
- package/esm/_dnt.polyfills.js +127 -1
- package/esm/_dnt.shims.d.ts +1 -0
- package/esm/_dnt.shims.d.ts.map +1 -0
- package/esm/deno.d.ts +2 -0
- package/esm/deno.d.ts.map +1 -0
- package/esm/deno.js +2 -1
- package/esm/src/commands/announcements.d.ts +3 -0
- package/esm/src/commands/announcements.d.ts.map +1 -0
- package/esm/src/commands/announcements.js +135 -0
- package/esm/src/commands/auth.d.ts +3 -0
- package/esm/src/commands/auth.d.ts.map +1 -0
- package/esm/src/commands/auth.js +264 -0
- package/esm/src/commands/calendar.d.ts +3 -0
- package/esm/src/commands/calendar.d.ts.map +1 -0
- package/esm/src/commands/calendar.js +180 -0
- package/esm/src/commands/courses.d.ts +3 -0
- package/esm/src/commands/courses.d.ts.map +1 -0
- package/esm/src/commands/courses.js +348 -0
- package/esm/src/commands/forums.d.ts +3 -0
- package/esm/src/commands/forums.d.ts.map +1 -0
- package/esm/src/commands/forums.js +231 -0
- package/esm/src/commands/grades.d.ts +3 -0
- package/esm/src/commands/grades.d.ts.map +1 -0
- package/esm/src/commands/grades.js +121 -0
- package/esm/src/commands/materials.d.ts +3 -0
- package/esm/src/commands/materials.d.ts.map +1 -0
- package/esm/src/commands/materials.js +362 -0
- package/esm/src/commands/quizzes.d.ts +3 -0
- package/esm/src/commands/quizzes.d.ts.map +1 -0
- package/esm/src/commands/quizzes.js +160 -0
- package/esm/src/commands/skills.d.ts +3 -0
- package/esm/src/commands/skills.d.ts.map +1 -0
- package/esm/src/commands/skills.js +110 -0
- package/esm/src/commands/videos.d.ts +3 -0
- package/esm/src/commands/videos.d.ts.map +1 -0
- package/esm/src/commands/videos.js +302 -0
- package/esm/src/index.d.ts +27 -0
- package/esm/src/index.d.ts.map +1 -0
- package/esm/src/index.js +149 -0
- package/esm/src/lib/auth.d.ts +25 -0
- package/esm/src/lib/auth.d.ts.map +1 -0
- package/esm/src/lib/auth.js +194 -0
- package/esm/src/lib/config.d.ts +6 -0
- package/esm/src/lib/config.d.ts.map +1 -0
- package/esm/src/lib/config.js +36 -0
- package/esm/src/lib/logger.d.ts +3 -0
- package/esm/src/lib/logger.d.ts.map +1 -0
- package/esm/src/lib/logger.js +24 -0
- package/esm/src/lib/moodle.d.ts +205 -0
- package/esm/src/lib/moodle.d.ts.map +1 -0
- package/esm/src/lib/moodle.js +690 -0
- package/esm/src/lib/session.d.ts +8 -0
- package/esm/src/lib/session.d.ts.map +1 -0
- package/esm/src/lib/session.js +42 -0
- package/esm/src/lib/token.d.ts +38 -0
- package/esm/src/lib/token.d.ts.map +1 -0
- package/esm/src/lib/token.js +178 -0
- package/esm/src/lib/types.d.ts +271 -0
- package/esm/src/lib/types.d.ts.map +1 -0
- package/esm/src/lib/types.js +1 -0
- package/esm/src/lib/utils.d.ts +17 -0
- package/esm/src/lib/utils.d.ts.map +1 -0
- package/esm/src/lib/utils.js +51 -0
- package/package.json +7 -3
- package/script/_dnt.polyfills.d.ts +83 -6
- package/script/_dnt.polyfills.d.ts.map +1 -0
- package/script/_dnt.polyfills.js +128 -0
- package/script/_dnt.shims.d.ts +1 -0
- package/script/_dnt.shims.d.ts.map +1 -0
- package/script/deno.d.ts +2 -0
- package/script/deno.d.ts.map +1 -0
- package/script/deno.js +2 -1
- package/script/src/commands/announcements.d.ts +1 -0
- package/script/src/commands/announcements.d.ts.map +1 -0
- package/script/src/commands/announcements.js +75 -222
- package/script/src/commands/auth.d.ts +1 -0
- package/script/src/commands/auth.d.ts.map +1 -0
- package/script/src/commands/auth.js +49 -17
- package/script/src/commands/calendar.d.ts +1 -0
- package/script/src/commands/calendar.d.ts.map +1 -0
- package/script/src/commands/calendar.js +112 -301
- package/script/src/commands/courses.d.ts +1 -0
- package/script/src/commands/courses.d.ts.map +1 -0
- package/script/src/commands/courses.js +43 -173
- package/script/src/commands/forums.d.ts +1 -0
- package/script/src/commands/forums.d.ts.map +1 -0
- package/script/src/commands/forums.js +145 -311
- package/script/src/commands/grades.d.ts +1 -0
- package/script/src/commands/grades.d.ts.map +1 -0
- package/script/src/commands/grades.js +62 -194
- package/script/src/commands/materials.d.ts +1 -0
- package/script/src/commands/materials.d.ts.map +1 -0
- package/script/src/commands/materials.js +111 -166
- package/script/src/commands/quizzes.d.ts +1 -0
- package/script/src/commands/quizzes.d.ts.map +1 -0
- package/script/src/commands/quizzes.js +40 -102
- package/script/src/commands/skills.d.ts +1 -0
- package/script/src/commands/skills.d.ts.map +1 -0
- package/script/src/commands/skills.js +17 -18
- package/script/src/commands/videos.d.ts +1 -0
- package/script/src/commands/videos.d.ts.map +1 -0
- package/script/src/commands/videos.js +26 -52
- package/script/src/index.d.ts +1 -0
- package/script/src/index.d.ts.map +1 -0
- package/script/src/index.js +4 -4
- package/script/src/lib/auth.d.ts +1 -0
- package/script/src/lib/auth.d.ts.map +1 -0
- package/script/src/lib/auth.js +9 -10
- package/script/src/lib/config.d.ts +1 -0
- package/script/src/lib/config.d.ts.map +1 -0
- package/script/src/lib/config.js +6 -7
- package/script/src/lib/logger.d.ts +1 -0
- package/script/src/lib/logger.d.ts.map +1 -0
- package/script/src/lib/logger.js +1 -2
- package/script/src/lib/moodle.d.ts +25 -54
- package/script/src/lib/moodle.d.ts.map +1 -0
- package/script/src/lib/moodle.js +103 -324
- package/script/src/lib/session.d.ts +1 -0
- package/script/src/lib/session.d.ts.map +1 -0
- package/script/src/lib/session.js +3 -29
- package/script/src/lib/token.d.ts +16 -5
- package/script/src/lib/token.d.ts.map +1 -0
- package/script/src/lib/token.js +71 -36
- package/script/src/lib/types.d.ts +10 -0
- package/script/src/lib/types.d.ts.map +1 -0
- package/script/src/lib/utils.d.ts +12 -0
- package/script/src/lib/utils.d.ts.map +1 -0
- package/script/src/lib/utils.js +57 -11
- package/skills/openape/SKILL.md +331 -328
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { getBaseDir } from "../lib/utils.js";
|
|
2
|
+
import { getEnrolledCoursesApi, getCourseGradesApi } from "../lib/moodle.js";
|
|
3
|
+
import { createLogger } from "../lib/logger.js";
|
|
4
|
+
import { loadWsToken } from "../lib/token.js";
|
|
5
|
+
import { formatAndOutput } from "../index.js";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import fs from "node:fs";
|
|
8
|
+
export function registerGradesCommand(program) {
|
|
9
|
+
const gradesCmd = program.command("grades");
|
|
10
|
+
gradesCmd.description("Grade operations");
|
|
11
|
+
function getOutputFormat(command) {
|
|
12
|
+
const opts = command.optsWithGlobals();
|
|
13
|
+
return opts.output || "json";
|
|
14
|
+
}
|
|
15
|
+
// Pure API context - no browser required (fast!)
|
|
16
|
+
async function createApiContext(options, command) {
|
|
17
|
+
const opts = command?.optsWithGlobals ? command.optsWithGlobals() : options;
|
|
18
|
+
const outputFormat = getOutputFormat(command || { optsWithGlobals: () => ({ output: "json" }) });
|
|
19
|
+
const silent = outputFormat === "json" && !opts.verbose;
|
|
20
|
+
const log = createLogger(opts.verbose, silent);
|
|
21
|
+
const baseDir = getBaseDir();
|
|
22
|
+
const sessionPath = path.resolve(baseDir, ".auth", "storage-state.json");
|
|
23
|
+
// Check if session exists
|
|
24
|
+
if (!fs.existsSync(sessionPath)) {
|
|
25
|
+
log.error("未找到登入 session。請先執行 'openape auth login' 進行登入。");
|
|
26
|
+
log.info(`Session 預期位置: ${sessionPath}`);
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
// Try to load WS token
|
|
30
|
+
const wsToken = loadWsToken(sessionPath);
|
|
31
|
+
if (!wsToken) {
|
|
32
|
+
log.error("未找到 WS token。請先執行 'openape auth login' 進行登入。");
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
return {
|
|
36
|
+
log,
|
|
37
|
+
session: {
|
|
38
|
+
wsToken,
|
|
39
|
+
moodleBaseUrl: "https://ilearning.cycu.edu.tw",
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
gradesCmd
|
|
44
|
+
.command("summary")
|
|
45
|
+
.description("Show grade summary across all courses")
|
|
46
|
+
.option("--output <format>", "Output format: json|csv|table|silent")
|
|
47
|
+
.action(async (options, command) => {
|
|
48
|
+
const output = getOutputFormat(command);
|
|
49
|
+
const apiContext = await createApiContext(options, command);
|
|
50
|
+
if (!apiContext) {
|
|
51
|
+
process.exitCode = 1;
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const courses = await getEnrolledCoursesApi(apiContext.session);
|
|
55
|
+
const gradeSummaries = [];
|
|
56
|
+
for (const course of courses) {
|
|
57
|
+
const grades = await getCourseGradesApi(apiContext.session, course.id);
|
|
58
|
+
gradeSummaries.push({
|
|
59
|
+
courseId: course.id,
|
|
60
|
+
courseName: course.fullname,
|
|
61
|
+
grade: grades.grade,
|
|
62
|
+
gradeFormatted: grades.gradeFormatted,
|
|
63
|
+
rank: grades.rank,
|
|
64
|
+
totalUsers: grades.totalUsers,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
// Calculate overall statistics
|
|
68
|
+
const gradedCourses = gradeSummaries.filter(g => g.grade !== undefined && g.grade !== null && g.grade !== "-");
|
|
69
|
+
const averageRank = gradeSummaries
|
|
70
|
+
.filter(g => g.rank !== undefined && g.rank !== null)
|
|
71
|
+
.reduce((sum, g) => sum + (g.rank || 0), 0) /
|
|
72
|
+
(gradeSummaries.filter(g => g.rank !== undefined && g.rank !== null).length || 1);
|
|
73
|
+
const summaryData = {
|
|
74
|
+
total_courses: courses.length,
|
|
75
|
+
graded_courses: gradedCourses.length,
|
|
76
|
+
average_rank: averageRank.toFixed(1),
|
|
77
|
+
grades: gradeSummaries,
|
|
78
|
+
};
|
|
79
|
+
formatAndOutput(summaryData, output, apiContext.log);
|
|
80
|
+
});
|
|
81
|
+
gradesCmd
|
|
82
|
+
.command("course")
|
|
83
|
+
.description("Show detailed grades for a specific course")
|
|
84
|
+
.argument("<course-id>", "Course ID")
|
|
85
|
+
.option("--output <format>", "Output format: json|csv|table|silent")
|
|
86
|
+
.action(async (courseId, options, command) => {
|
|
87
|
+
const output = getOutputFormat(command);
|
|
88
|
+
const apiContext = await createApiContext(options, command);
|
|
89
|
+
if (!apiContext) {
|
|
90
|
+
process.exitCode = 1;
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
const courses = await getEnrolledCoursesApi(apiContext.session);
|
|
94
|
+
const course = courses.find(c => c.id === parseInt(courseId, 10));
|
|
95
|
+
if (!course) {
|
|
96
|
+
apiContext.log.error(`Course not found: ${courseId}`);
|
|
97
|
+
process.exitCode = 1;
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
const grades = await getCourseGradesApi(apiContext.session, course.id);
|
|
101
|
+
const gradeData = {
|
|
102
|
+
courseId: grades.courseId,
|
|
103
|
+
courseName: grades.courseName,
|
|
104
|
+
grade: grades.grade,
|
|
105
|
+
gradeFormatted: grades.gradeFormatted,
|
|
106
|
+
rank: grades.rank,
|
|
107
|
+
totalUsers: grades.totalUsers,
|
|
108
|
+
items: grades.items?.map(item => ({
|
|
109
|
+
name: item.name,
|
|
110
|
+
grade: item.grade,
|
|
111
|
+
gradeFormatted: item.gradeFormatted,
|
|
112
|
+
range: item.range,
|
|
113
|
+
percentage: item.percentage,
|
|
114
|
+
weight: item.weight,
|
|
115
|
+
feedback: item.feedback,
|
|
116
|
+
graded: item.graded,
|
|
117
|
+
})),
|
|
118
|
+
};
|
|
119
|
+
formatAndOutput(gradeData, output, apiContext.log);
|
|
120
|
+
});
|
|
121
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"materials.d.ts","sourceRoot":"","sources":["../../../src/src/commands/materials.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AA+BpC,wBAAgB,wBAAwB,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CAka/D"}
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
import { getBaseDir } from "../lib/utils.js";
|
|
2
|
+
import { getEnrolledCourses, getEnrolledCoursesApi, getResourcesByCoursesApi } 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";
|
|
7
|
+
import { loadWsToken } from "../lib/token.js";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import fs from "node:fs";
|
|
10
|
+
export function registerMaterialsCommand(program) {
|
|
11
|
+
const materialsCmd = program.command("materials");
|
|
12
|
+
materialsCmd.description("Material/resource operations");
|
|
13
|
+
// Helper to get output format from global or local options
|
|
14
|
+
function getOutputFormat(command) {
|
|
15
|
+
const opts = command.optsWithGlobals();
|
|
16
|
+
return opts.output || "json";
|
|
17
|
+
}
|
|
18
|
+
// Pure API context - no browser required (fast!)
|
|
19
|
+
async function createApiContext(options, command) {
|
|
20
|
+
const opts = command?.optsWithGlobals ? command.optsWithGlobals() : options;
|
|
21
|
+
const outputFormat = getOutputFormat(command || { optsWithGlobals: () => ({ output: "json" }) });
|
|
22
|
+
const silent = outputFormat === "json" && !opts.verbose;
|
|
23
|
+
const log = createLogger(opts.verbose, silent);
|
|
24
|
+
const baseDir = getBaseDir();
|
|
25
|
+
const sessionPath = path.resolve(baseDir, ".auth", "storage-state.json");
|
|
26
|
+
// Check if session exists
|
|
27
|
+
if (!fs.existsSync(sessionPath)) {
|
|
28
|
+
log.error("未找到登入 session。請先執行 'openape auth login' 進行登入。");
|
|
29
|
+
log.info(`Session 預期位置: ${sessionPath}`);
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
// Try to load WS token
|
|
33
|
+
const wsToken = loadWsToken(sessionPath);
|
|
34
|
+
if (!wsToken) {
|
|
35
|
+
log.error("未找到 WS token。請先執行 'openape auth login' 進行登入。");
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
return {
|
|
39
|
+
log,
|
|
40
|
+
session: {
|
|
41
|
+
wsToken,
|
|
42
|
+
moodleBaseUrl: "https://ilearning.cycu.edu.tw",
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
// Helper function to create session context (for download commands)
|
|
47
|
+
async function createSessionContext(options, command) {
|
|
48
|
+
const opts = command?.optsWithGlobals ? command.optsWithGlobals() : options;
|
|
49
|
+
const outputFormat = getOutputFormat(command || { optsWithGlobals: () => ({ output: "json" }) });
|
|
50
|
+
const silent = outputFormat === "json" && !opts.verbose;
|
|
51
|
+
const log = createLogger(opts.verbose, silent);
|
|
52
|
+
const baseDir = getBaseDir();
|
|
53
|
+
const sessionPath = path.resolve(baseDir, ".auth", "storage-state.json");
|
|
54
|
+
if (!fs.existsSync(sessionPath)) {
|
|
55
|
+
log.error("未找到登入 session。請先執行 'openape auth login' 進行登入。");
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
const config = {
|
|
59
|
+
username: "",
|
|
60
|
+
password: "",
|
|
61
|
+
courseUrl: "",
|
|
62
|
+
moodleBaseUrl: "https://ilearning.cycu.edu.tw",
|
|
63
|
+
headless: !options.headed,
|
|
64
|
+
slowMo: 0,
|
|
65
|
+
authStatePath: sessionPath,
|
|
66
|
+
ollamaBaseUrl: "",
|
|
67
|
+
};
|
|
68
|
+
log.info("啟動瀏覽器...");
|
|
69
|
+
const { browser, context, page } = await launchAuthenticated(config, log);
|
|
70
|
+
try {
|
|
71
|
+
const session = await extractSessionInfo(page, config, log);
|
|
72
|
+
return { log, page, session, browser, context };
|
|
73
|
+
}
|
|
74
|
+
catch (err) {
|
|
75
|
+
await context.close();
|
|
76
|
+
await browser.close();
|
|
77
|
+
throw err;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
// Helper to sanitize filenames
|
|
81
|
+
function sanitizeFilename(name) {
|
|
82
|
+
return name.replace(/[<>:"/\\|?*]/g, "_").replace(/\s+/g, "_");
|
|
83
|
+
}
|
|
84
|
+
// Helper to download a single resource
|
|
85
|
+
async function downloadResource(page, resource, outputDir, log) {
|
|
86
|
+
try {
|
|
87
|
+
// Only download resource type (skip url)
|
|
88
|
+
if (resource.modType !== "resource") {
|
|
89
|
+
log.debug(` Skipping ${resource.modType}: ${resource.name}`);
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
// Create course directory
|
|
93
|
+
const courseDir = path.join(outputDir, sanitizeFilename(resource.course_name));
|
|
94
|
+
if (!fs.existsSync(courseDir)) {
|
|
95
|
+
fs.mkdirSync(courseDir, { recursive: true });
|
|
96
|
+
}
|
|
97
|
+
// Navigate to resource page
|
|
98
|
+
log.debug(` Downloading: ${resource.name}`);
|
|
99
|
+
await page.goto(resource.url, { waitUntil: "domcontentloaded", timeout: 30000 });
|
|
100
|
+
// Try to find download link on the page
|
|
101
|
+
const downloadLinks = await page.$$eval('a[href*="forcedownload=1"]', (links) => links.map((a) => a.href));
|
|
102
|
+
if (downloadLinks.length === 0) {
|
|
103
|
+
log.warn(` No download link found for: ${resource.name}`);
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
// Download the first available file
|
|
107
|
+
const downloadUrl = downloadLinks[0];
|
|
108
|
+
// Extract filename from URL or use resource name
|
|
109
|
+
const urlObj = new URL(downloadUrl);
|
|
110
|
+
const filenameParam = urlObj.searchParams.get("filename");
|
|
111
|
+
let filename = filenameParam || sanitizeFilename(resource.name);
|
|
112
|
+
// Add extension if missing
|
|
113
|
+
if (resource.mimetype && !path.extname(filename)) {
|
|
114
|
+
const extMap = {
|
|
115
|
+
"application/pdf": ".pdf",
|
|
116
|
+
"application/vnd.ms-powerpoint": ".ppt",
|
|
117
|
+
"application/vnd.openxmlformats-officedocument.presentationml.presentation": ".pptx",
|
|
118
|
+
"application/msword": ".doc",
|
|
119
|
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx",
|
|
120
|
+
"application/vnd.ms-excel": ".xls",
|
|
121
|
+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx",
|
|
122
|
+
"application/zip": ".zip",
|
|
123
|
+
"image/jpeg": ".jpg",
|
|
124
|
+
"image/png": ".png",
|
|
125
|
+
};
|
|
126
|
+
if (extMap[resource.mimetype]) {
|
|
127
|
+
filename += extMap[resource.mimetype];
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
const outputPath = path.join(courseDir, filename);
|
|
131
|
+
// Trigger download
|
|
132
|
+
const downloadPromise = page.waitForEvent("download", { timeout: 30000 });
|
|
133
|
+
await page.goto(downloadUrl, { waitUntil: "domcontentloaded" });
|
|
134
|
+
const download = await downloadPromise;
|
|
135
|
+
// Save file
|
|
136
|
+
await download.saveAs(outputPath);
|
|
137
|
+
const stats = fs.statSync(outputPath);
|
|
138
|
+
log.success(` Downloaded: ${filename} (${(stats.size / 1024).toFixed(1)} KB)`);
|
|
139
|
+
return {
|
|
140
|
+
filename,
|
|
141
|
+
path: outputPath,
|
|
142
|
+
size: stats.size,
|
|
143
|
+
course_id: resource.course_id,
|
|
144
|
+
course_name: resource.course_name,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
catch (err) {
|
|
148
|
+
log.warn(` Failed to download ${resource.name}: ${err instanceof Error ? err.message : String(err)}`);
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
materialsCmd
|
|
153
|
+
.command("list-all")
|
|
154
|
+
.description("List all materials/resources across all courses")
|
|
155
|
+
.option("--level <type>", "Course level: in_progress (default) | all", "in_progress")
|
|
156
|
+
.option("--output <format>", "Output format: json|csv|table|silent")
|
|
157
|
+
.action(async (options, command) => {
|
|
158
|
+
const apiContext = await createApiContext(options, command);
|
|
159
|
+
if (!apiContext) {
|
|
160
|
+
process.exitCode = 1;
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
const classification = options.level === "all" ? undefined : "inprogress";
|
|
164
|
+
const courses = await getEnrolledCoursesApi(apiContext.session, {
|
|
165
|
+
classification,
|
|
166
|
+
});
|
|
167
|
+
// Get materials via WS API (no browser needed!)
|
|
168
|
+
const courseIds = courses.map(c => c.id);
|
|
169
|
+
const apiResources = await getResourcesByCoursesApi(apiContext.session, courseIds);
|
|
170
|
+
// Build a map of courseId -> course for quick lookup
|
|
171
|
+
const courseMap = new Map(courses.map(c => [c.id, c]));
|
|
172
|
+
const allMaterials = [];
|
|
173
|
+
for (const resource of apiResources) {
|
|
174
|
+
const course = courseMap.get(resource.courseId);
|
|
175
|
+
if (course) {
|
|
176
|
+
allMaterials.push({
|
|
177
|
+
course_id: resource.courseId,
|
|
178
|
+
course_name: course.fullname,
|
|
179
|
+
cmid: resource.cmid,
|
|
180
|
+
name: resource.name,
|
|
181
|
+
url: resource.url,
|
|
182
|
+
modType: resource.modType,
|
|
183
|
+
mimetype: resource.mimetype,
|
|
184
|
+
filesize: resource.filesize,
|
|
185
|
+
modified: resource.modified,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
const output = {
|
|
190
|
+
status: "success",
|
|
191
|
+
timestamp: new Date().toISOString(),
|
|
192
|
+
level: options.level,
|
|
193
|
+
materials: allMaterials.map(m => ({
|
|
194
|
+
course_id: m.course_id,
|
|
195
|
+
course_name: m.course_name,
|
|
196
|
+
id: m.cmid,
|
|
197
|
+
name: m.name,
|
|
198
|
+
type: m.modType,
|
|
199
|
+
mimetype: m.mimetype,
|
|
200
|
+
filesize: m.filesize,
|
|
201
|
+
modified: m.modified ? new Date(m.modified * 1000).toISOString() : null,
|
|
202
|
+
url: m.url,
|
|
203
|
+
})),
|
|
204
|
+
summary: {
|
|
205
|
+
total_courses: courses.length,
|
|
206
|
+
total_materials: allMaterials.length,
|
|
207
|
+
by_type: allMaterials.reduce((acc, m) => {
|
|
208
|
+
acc[m.modType] = (acc[m.modType] || 0) + 1;
|
|
209
|
+
return acc;
|
|
210
|
+
}, {}),
|
|
211
|
+
},
|
|
212
|
+
};
|
|
213
|
+
console.log(JSON.stringify(output));
|
|
214
|
+
});
|
|
215
|
+
materialsCmd
|
|
216
|
+
.command("download")
|
|
217
|
+
.description("Download all materials from a specific course (requires browser)")
|
|
218
|
+
.argument("<course-id>", "Course ID")
|
|
219
|
+
.option("--output-dir <path>", "Output directory", "./downloads")
|
|
220
|
+
.action(async (courseId, options, command) => {
|
|
221
|
+
const context = await createSessionContext(options, command);
|
|
222
|
+
if (!context) {
|
|
223
|
+
process.exitCode = 1;
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
const { log, page, session, browser, context: browserContext } = context;
|
|
227
|
+
try {
|
|
228
|
+
const courses = await getEnrolledCourses(page, session, log);
|
|
229
|
+
const course = courses.find(c => c.id === parseInt(courseId, 10));
|
|
230
|
+
if (!course) {
|
|
231
|
+
log.error(`Course not found: ${courseId}`);
|
|
232
|
+
process.exitCode = 1;
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
// Navigate to course page to find materials
|
|
236
|
+
await page.goto(`https://ilearning.cycu.edu.tw/course/view.php?id=${course.id}`, { waitUntil: "domcontentloaded" });
|
|
237
|
+
// Find all resource links
|
|
238
|
+
const materials = [];
|
|
239
|
+
const resourceLinks = await page.$$eval('a[href*="/mod/resource/view.php"]', (links) => {
|
|
240
|
+
return links.map((a) => ({
|
|
241
|
+
url: a.href,
|
|
242
|
+
name: a.textContent?.trim() || "",
|
|
243
|
+
}));
|
|
244
|
+
});
|
|
245
|
+
for (const link of resourceLinks) {
|
|
246
|
+
const cmidMatch = link.url.match(/id=(\d+)/);
|
|
247
|
+
if (cmidMatch) {
|
|
248
|
+
materials.push({
|
|
249
|
+
course_id: course.id,
|
|
250
|
+
course_name: course.fullname,
|
|
251
|
+
cmid: cmidMatch[1],
|
|
252
|
+
name: link.name,
|
|
253
|
+
url: link.url,
|
|
254
|
+
modType: "resource",
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
log.info(`Found ${materials.length} materials in course: ${course.fullname}`);
|
|
259
|
+
const downloadedFiles = [];
|
|
260
|
+
for (const material of materials) {
|
|
261
|
+
const result = await downloadResource(page, material, options.outputDir, log);
|
|
262
|
+
if (result) {
|
|
263
|
+
downloadedFiles.push(result);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
const summary = {
|
|
267
|
+
total_materials: materials.length,
|
|
268
|
+
downloaded: downloadedFiles.length,
|
|
269
|
+
skipped: materials.length - downloadedFiles.length,
|
|
270
|
+
total_size: downloadedFiles.reduce((sum, f) => sum + f.size, 0),
|
|
271
|
+
};
|
|
272
|
+
const output = {
|
|
273
|
+
status: "success",
|
|
274
|
+
timestamp: new Date().toISOString(),
|
|
275
|
+
downloaded_files: downloadedFiles.map(f => ({
|
|
276
|
+
filename: f.filename,
|
|
277
|
+
path: f.path,
|
|
278
|
+
size: f.size,
|
|
279
|
+
course_id: f.course_id,
|
|
280
|
+
course_name: f.course_name,
|
|
281
|
+
})),
|
|
282
|
+
summary,
|
|
283
|
+
};
|
|
284
|
+
console.log(JSON.stringify(output));
|
|
285
|
+
}
|
|
286
|
+
finally {
|
|
287
|
+
await closeBrowserSafely(browser, browserContext);
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
materialsCmd
|
|
291
|
+
.command("download-all")
|
|
292
|
+
.description("Download all materials from all courses (requires browser)")
|
|
293
|
+
.option("--output-dir <path>", "Output directory", "./downloads")
|
|
294
|
+
.option("--level <type>", "Course level: in_progress (default) | all", "in_progress")
|
|
295
|
+
.action(async (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 classification = options.level === "all" ? undefined : "inprogress";
|
|
304
|
+
const courses = await getEnrolledCourses(page, session, log, { classification });
|
|
305
|
+
log.info(`Scanning ${courses.length} courses for materials...`);
|
|
306
|
+
const allMaterials = [];
|
|
307
|
+
for (const course of courses) {
|
|
308
|
+
await page.goto(`https://ilearning.cycu.edu.tw/course/view.php?id=${course.id}`, { waitUntil: "domcontentloaded" });
|
|
309
|
+
const resourceLinks = await page.$$eval('a[href*="/mod/resource/view.php"]', (links) => {
|
|
310
|
+
return links.map((a) => ({
|
|
311
|
+
url: a.href,
|
|
312
|
+
name: a.textContent?.trim() || "",
|
|
313
|
+
}));
|
|
314
|
+
});
|
|
315
|
+
for (const link of resourceLinks) {
|
|
316
|
+
const cmidMatch = link.url.match(/id=(\d+)/);
|
|
317
|
+
if (cmidMatch) {
|
|
318
|
+
allMaterials.push({
|
|
319
|
+
course_id: course.id,
|
|
320
|
+
course_name: course.fullname,
|
|
321
|
+
cmid: cmidMatch[1],
|
|
322
|
+
name: link.name,
|
|
323
|
+
url: link.url,
|
|
324
|
+
modType: "resource",
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
log.info(`Found ${allMaterials.length} materials across ${courses.length} courses`);
|
|
330
|
+
const downloadedFiles = [];
|
|
331
|
+
for (const material of allMaterials) {
|
|
332
|
+
const result = await downloadResource(page, material, options.outputDir, log);
|
|
333
|
+
if (result) {
|
|
334
|
+
downloadedFiles.push(result);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
const summary = {
|
|
338
|
+
total_courses: courses.length,
|
|
339
|
+
total_materials: allMaterials.length,
|
|
340
|
+
downloaded: downloadedFiles.length,
|
|
341
|
+
skipped: allMaterials.length - downloadedFiles.length,
|
|
342
|
+
total_size: downloadedFiles.reduce((sum, f) => sum + f.size, 0),
|
|
343
|
+
};
|
|
344
|
+
const output = {
|
|
345
|
+
status: "success",
|
|
346
|
+
timestamp: new Date().toISOString(),
|
|
347
|
+
downloaded_files: downloadedFiles.map(f => ({
|
|
348
|
+
filename: f.filename,
|
|
349
|
+
path: f.path,
|
|
350
|
+
size: f.size,
|
|
351
|
+
course_id: f.course_id,
|
|
352
|
+
course_name: f.course_name,
|
|
353
|
+
})),
|
|
354
|
+
summary,
|
|
355
|
+
};
|
|
356
|
+
console.log(JSON.stringify(output));
|
|
357
|
+
}
|
|
358
|
+
finally {
|
|
359
|
+
await closeBrowserSafely(browser, browserContext);
|
|
360
|
+
}
|
|
361
|
+
});
|
|
362
|
+
}
|
|
@@ -0,0 +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"}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { getBaseDir } from "../lib/utils.js";
|
|
2
|
+
import { getEnrolledCoursesApi, getQuizzesByCoursesApi } 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";
|
|
7
|
+
import { formatAndOutput } from "../index.js";
|
|
8
|
+
import { loadWsToken } from "../lib/token.js";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import fs from "node:fs";
|
|
11
|
+
export function registerQuizzesCommand(program) {
|
|
12
|
+
const quizzesCmd = program.command("quizzes");
|
|
13
|
+
quizzesCmd.description("Quiz operations");
|
|
14
|
+
function getOutputFormat(command) {
|
|
15
|
+
const opts = command.optsWithGlobals();
|
|
16
|
+
return opts.output || "json";
|
|
17
|
+
}
|
|
18
|
+
// Pure API context - no browser required (fast!)
|
|
19
|
+
async function createApiContext(options, command) {
|
|
20
|
+
const opts = command?.optsWithGlobals ? command.optsWithGlobals() : options;
|
|
21
|
+
const outputFormat = getOutputFormat(command || { optsWithGlobals: () => ({ output: "json" }) });
|
|
22
|
+
const silent = outputFormat === "json" && !opts.verbose;
|
|
23
|
+
const log = createLogger(opts.verbose, silent);
|
|
24
|
+
const baseDir = getBaseDir();
|
|
25
|
+
const sessionPath = path.resolve(baseDir, ".auth", "storage-state.json");
|
|
26
|
+
// Check if session exists
|
|
27
|
+
if (!fs.existsSync(sessionPath)) {
|
|
28
|
+
log.error("未找到登入 session。請先執行 'openape auth login' 進行登入。");
|
|
29
|
+
log.info(`Session 預期位置: ${sessionPath}`);
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
// Try to load WS token
|
|
33
|
+
const wsToken = loadWsToken(sessionPath);
|
|
34
|
+
if (!wsToken) {
|
|
35
|
+
log.error("未找到 WS token。請先執行 'openape auth login' 進行登入。");
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
return {
|
|
39
|
+
log,
|
|
40
|
+
session: {
|
|
41
|
+
wsToken,
|
|
42
|
+
moodleBaseUrl: "https://ilearning.cycu.edu.tw",
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
// Helper function to create session context (for open command only)
|
|
47
|
+
async function createSessionContext(options, command) {
|
|
48
|
+
const opts = command?.optsWithGlobals ? command.optsWithGlobals() : options;
|
|
49
|
+
const outputFormat = getOutputFormat(command || { optsWithGlobals: () => ({ output: "json" }) });
|
|
50
|
+
const silent = outputFormat === "json" && !opts.verbose;
|
|
51
|
+
const log = createLogger(opts.verbose, silent);
|
|
52
|
+
const baseDir = getBaseDir();
|
|
53
|
+
const sessionPath = path.resolve(baseDir, ".auth", "storage-state.json");
|
|
54
|
+
if (!fs.existsSync(sessionPath)) {
|
|
55
|
+
log.error("未找到登入 session。請先執行 'openape auth login' 進行登入。");
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
const config = {
|
|
59
|
+
username: "",
|
|
60
|
+
password: "",
|
|
61
|
+
courseUrl: "",
|
|
62
|
+
moodleBaseUrl: "https://ilearning.cycu.edu.tw",
|
|
63
|
+
headless: !options.headed,
|
|
64
|
+
slowMo: 0,
|
|
65
|
+
authStatePath: sessionPath,
|
|
66
|
+
ollamaBaseUrl: "",
|
|
67
|
+
};
|
|
68
|
+
log.info("啟動瀏覽器...");
|
|
69
|
+
const { browser, context, page } = await launchAuthenticated(config, log);
|
|
70
|
+
try {
|
|
71
|
+
const session = await extractSessionInfo(page, config, log);
|
|
72
|
+
return { log, page, session, browser, context };
|
|
73
|
+
}
|
|
74
|
+
catch (err) {
|
|
75
|
+
await context.close();
|
|
76
|
+
await browser.close();
|
|
77
|
+
throw err;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
quizzesCmd
|
|
81
|
+
.command("list")
|
|
82
|
+
.description("List quizzes in a course")
|
|
83
|
+
.argument("<course-id>", "Course ID")
|
|
84
|
+
.option("--available-only", "Show only available quizzes")
|
|
85
|
+
.option("--output <format>", "Output format: json|csv|table|silent")
|
|
86
|
+
.action(async (courseId, options, command) => {
|
|
87
|
+
const output = getOutputFormat(command);
|
|
88
|
+
const apiContext = await createApiContext(options, command);
|
|
89
|
+
if (!apiContext) {
|
|
90
|
+
process.exitCode = 1;
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
const quizzes = await getQuizzesByCoursesApi(apiContext.session, [parseInt(courseId, 10)]);
|
|
94
|
+
// Note: API doesn't provide completion status, so --available-only shows all
|
|
95
|
+
if (options.availableOnly) {
|
|
96
|
+
apiContext.log.warn("--available-only is not supported in API mode, showing all quizzes");
|
|
97
|
+
}
|
|
98
|
+
formatAndOutput(quizzes, output, apiContext.log);
|
|
99
|
+
});
|
|
100
|
+
quizzesCmd
|
|
101
|
+
.command("list-all")
|
|
102
|
+
.description("List all available quizzes across all courses")
|
|
103
|
+
.option("--level <type>", "Course level: in_progress (default) | all", "in_progress")
|
|
104
|
+
.option("--output <format>", "Output format: json|csv|table|silent")
|
|
105
|
+
.action(async (options, command) => {
|
|
106
|
+
const output = getOutputFormat(command);
|
|
107
|
+
const apiContext = await createApiContext(options, command);
|
|
108
|
+
if (!apiContext) {
|
|
109
|
+
process.exitCode = 1;
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
const classification = options.level === "all" ? undefined : "inprogress";
|
|
113
|
+
const courses = await getEnrolledCoursesApi(apiContext.session, {
|
|
114
|
+
classification,
|
|
115
|
+
});
|
|
116
|
+
// Get quizzes via WS API (no browser needed!)
|
|
117
|
+
const courseIds = courses.map(c => c.id);
|
|
118
|
+
const apiQuizzes = await getQuizzesByCoursesApi(apiContext.session, courseIds);
|
|
119
|
+
// Build a map of courseId -> course for quick lookup
|
|
120
|
+
const courseMap = new Map(courses.map(c => [c.id, c]));
|
|
121
|
+
const allQuizzes = [];
|
|
122
|
+
for (const q of apiQuizzes) {
|
|
123
|
+
const course = courseMap.get(q.courseId);
|
|
124
|
+
if (course) {
|
|
125
|
+
allQuizzes.push({
|
|
126
|
+
courseName: course.fullname,
|
|
127
|
+
name: q.name,
|
|
128
|
+
url: q.url,
|
|
129
|
+
cmid: q.cmid,
|
|
130
|
+
isComplete: q.isComplete,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
apiContext.log.info(`\n總計發現 ${allQuizzes.length} 個測驗。`);
|
|
135
|
+
formatAndOutput(allQuizzes, output, apiContext.log);
|
|
136
|
+
});
|
|
137
|
+
quizzesCmd
|
|
138
|
+
.command("open")
|
|
139
|
+
.description("Open a quiz URL in browser (manual mode)")
|
|
140
|
+
.argument("<quiz-url>", "Quiz URL")
|
|
141
|
+
.option("--headed", "Run browser in visible mode (default: true)")
|
|
142
|
+
.action(async (quizUrl, options, command) => {
|
|
143
|
+
const context = await createSessionContext({ ...options, headed: true }, command);
|
|
144
|
+
if (!context) {
|
|
145
|
+
process.exitCode = 1;
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
const { log, page, browser, context: browserContext } = context;
|
|
149
|
+
try {
|
|
150
|
+
log.info(`導航至測驗頁面: ${quizUrl}`);
|
|
151
|
+
await page.goto(quizUrl, { waitUntil: "domcontentloaded" });
|
|
152
|
+
log.info("瀏覽器已開啟,請手動完成測驗。");
|
|
153
|
+
log.info("按 Ctrl+C 關閉瀏覽器。");
|
|
154
|
+
await new Promise(() => { });
|
|
155
|
+
}
|
|
156
|
+
finally {
|
|
157
|
+
await closeBrowserSafely(browser, browserContext);
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"skills.d.ts","sourceRoot":"","sources":["../../../src/src/commands/skills.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AA6CpC,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CA0E5D"}
|