@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,117 @@
|
|
|
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.registerSkillsCommand = void 0;
|
|
7
|
+
const fs_1 = __importDefault(require("fs"));
|
|
8
|
+
const path_1 = __importDefault(require("path"));
|
|
9
|
+
const os_1 = __importDefault(require("os"));
|
|
10
|
+
const SKILL_NAME = "openape";
|
|
11
|
+
const GITHUB_RAW_URL = `https://raw.githubusercontent.com/mo7yw4ng/openape/refs/heads/main/skills/${SKILL_NAME}/SKILL.md`;
|
|
12
|
+
/**
|
|
13
|
+
* Known agent platforms and their skills directories
|
|
14
|
+
*/
|
|
15
|
+
const PLATFORMS = {
|
|
16
|
+
claude: { name: "Claude Code", path: path_1.default.join(os_1.default.homedir(), ".claude", "skills") },
|
|
17
|
+
codex: { name: "Codex CLI", path: path_1.default.join(os_1.default.homedir(), ".codex", "skills") },
|
|
18
|
+
opencode: { name: "OpenCode", path: path_1.default.join(os_1.default.homedir(), ".opencode", "skills") },
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* Try to read SKILL.md from local project first (dev mode / bundled build),
|
|
22
|
+
* fallback to fetching from GitHub (when installed globally via npm).
|
|
23
|
+
*/
|
|
24
|
+
async function readSkillContent() {
|
|
25
|
+
// Try local path first (relative to this file's location)
|
|
26
|
+
try {
|
|
27
|
+
const base = path_1.default.dirname(new URL(require("url").pathToFileURL(__filename).href).pathname);
|
|
28
|
+
const normalized = process.platform === "win32" ? base.replace(/^\//, "") : base;
|
|
29
|
+
// When running from source: src/commands/ → ../../skills/openape/SKILL.md
|
|
30
|
+
// When bundled by dnt into build/: esm/commands/ or script/ → ../../skills/openape/SKILL.md
|
|
31
|
+
const localPath = path_1.default.resolve(normalized, "..", "..", "skills", SKILL_NAME, "SKILL.md");
|
|
32
|
+
if (fs_1.default.existsSync(localPath)) {
|
|
33
|
+
return fs_1.default.readFileSync(localPath, "utf-8");
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
// import.meta.url may be unavailable in some environments
|
|
38
|
+
}
|
|
39
|
+
// Fallback: fetch from GitHub
|
|
40
|
+
const res = await fetch(GITHUB_RAW_URL, { headers: { "User-Agent": "openape-cli" } });
|
|
41
|
+
if (!res.ok) {
|
|
42
|
+
throw new Error(`Failed to fetch skill from GitHub: ${res.status} ${res.statusText}`);
|
|
43
|
+
}
|
|
44
|
+
return res.text();
|
|
45
|
+
}
|
|
46
|
+
function registerSkillsCommand(program) {
|
|
47
|
+
const skills = program
|
|
48
|
+
.command("skills")
|
|
49
|
+
.description("Manage OpenApe skills for AI agents");
|
|
50
|
+
skills
|
|
51
|
+
.command("install [platform]")
|
|
52
|
+
.description("Install the OpenApe skill to an agent platform (claude, codex, opencode)")
|
|
53
|
+
.option("--all", "Detect installed agents and install to all")
|
|
54
|
+
.action(async (platform, opts) => {
|
|
55
|
+
try {
|
|
56
|
+
let targets = [];
|
|
57
|
+
if (opts?.all) {
|
|
58
|
+
for (const [key, info] of Object.entries(PLATFORMS)) {
|
|
59
|
+
const parentDir = path_1.default.dirname(info.path);
|
|
60
|
+
if (fs_1.default.existsSync(parentDir)) {
|
|
61
|
+
targets.push({ key, ...info });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (targets.length === 0) {
|
|
65
|
+
console.log("No supported agents detected. Supported platforms: " + Object.keys(PLATFORMS).join(", "));
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
else if (platform) {
|
|
70
|
+
const info = PLATFORMS[platform.toLowerCase()];
|
|
71
|
+
if (!info) {
|
|
72
|
+
console.error(`Unknown platform: ${platform}`);
|
|
73
|
+
console.error(`Supported platforms: ${Object.keys(PLATFORMS).join(", ")}`);
|
|
74
|
+
process.exitCode = 1;
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
targets = [{ key: platform.toLowerCase(), ...info }];
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
console.error("Specify a platform or use --all.");
|
|
81
|
+
console.error(`Example: openape skills install claude`);
|
|
82
|
+
process.exitCode = 1;
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
console.log(`Fetching ${SKILL_NAME} skill...`);
|
|
86
|
+
const content = await readSkillContent();
|
|
87
|
+
for (const target of targets) {
|
|
88
|
+
console.log(`Installing to ${target.name} (${target.path})...`);
|
|
89
|
+
const destDir = path_1.default.join(target.path, SKILL_NAME);
|
|
90
|
+
if (!fs_1.default.existsSync(destDir)) {
|
|
91
|
+
fs_1.default.mkdirSync(destDir, { recursive: true });
|
|
92
|
+
}
|
|
93
|
+
fs_1.default.writeFileSync(path_1.default.join(destDir, "SKILL.md"), content, "utf-8");
|
|
94
|
+
console.log(` \x1b[32m✔\x1b[0m ${SKILL_NAME} installed!`);
|
|
95
|
+
}
|
|
96
|
+
console.log("\nDone!");
|
|
97
|
+
}
|
|
98
|
+
catch (err) {
|
|
99
|
+
console.error(`\x1b[31mFailed to install skill: ${err.message}\x1b[0m`);
|
|
100
|
+
process.exitCode = 1;
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
skills
|
|
104
|
+
.command("show")
|
|
105
|
+
.description("Print the raw SKILL.md content")
|
|
106
|
+
.action(async () => {
|
|
107
|
+
try {
|
|
108
|
+
const content = await readSkillContent();
|
|
109
|
+
process.stdout.write(content);
|
|
110
|
+
}
|
|
111
|
+
catch (err) {
|
|
112
|
+
console.error(`\x1b[31mFailed: ${err.message}\x1b[0m`);
|
|
113
|
+
process.exitCode = 1;
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
exports.registerSkillsCommand = registerSkillsCommand;
|
|
@@ -0,0 +1,334 @@
|
|
|
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.registerVideosCommand = 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 registerVideosCommand(program) {
|
|
18
|
+
const videosCmd = program.command("videos");
|
|
19
|
+
videosCmd.description("Video progress operations");
|
|
20
|
+
// Helper to get output format from global or local options
|
|
21
|
+
function getOutputFormat(command) {
|
|
22
|
+
const opts = command.optsWithGlobals();
|
|
23
|
+
return opts.output || "json";
|
|
24
|
+
}
|
|
25
|
+
// Pure API context - no browser required (fast!)
|
|
26
|
+
async function createApiContext(options, command) {
|
|
27
|
+
const opts = command?.optsWithGlobals ? command.optsWithGlobals() : options;
|
|
28
|
+
const outputFormat = getOutputFormat(command || { optsWithGlobals: () => ({ output: "json" }) });
|
|
29
|
+
const silent = outputFormat === "json" && !opts.verbose;
|
|
30
|
+
const log = (0, logger_js_1.createLogger)(opts.verbose, silent);
|
|
31
|
+
const baseDir = (0, utils_js_1.getBaseDir)();
|
|
32
|
+
const sessionPath = path_1.default.resolve(baseDir, ".auth", "storage-state.json");
|
|
33
|
+
// Check if session exists
|
|
34
|
+
if (!fs_1.default.existsSync(sessionPath)) {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
// Try to load WS token
|
|
38
|
+
const wsToken = (0, token_js_1.loadWsToken)(sessionPath);
|
|
39
|
+
if (!wsToken) {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
log,
|
|
44
|
+
session: {
|
|
45
|
+
wsToken,
|
|
46
|
+
moodleBaseUrl: "https://ilearning.cycu.edu.tw",
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
// Helper function to create session context
|
|
51
|
+
async function createSessionContext(options, command) {
|
|
52
|
+
// Get global options if command is provided (for --verbose, --silent flags)
|
|
53
|
+
const opts = command?.optsWithGlobals ? command.optsWithGlobals() : options;
|
|
54
|
+
// Auto-enable silent mode for JSON output (unless --verbose is also set)
|
|
55
|
+
const outputFormat = getOutputFormat(command || { optsWithGlobals: () => ({ output: "json" }) });
|
|
56
|
+
const silent = outputFormat === "json" && !opts.verbose;
|
|
57
|
+
const log = (0, logger_js_1.createLogger)(opts.verbose, silent);
|
|
58
|
+
const baseDir = (0, utils_js_1.getBaseDir)();
|
|
59
|
+
const sessionPath = path_1.default.resolve(baseDir, ".auth", "storage-state.json");
|
|
60
|
+
if (!fs_1.default.existsSync(sessionPath)) {
|
|
61
|
+
log.error("未找到登入 session。請先執行 'openape auth login' 進行登入。");
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
const config = {
|
|
65
|
+
username: "",
|
|
66
|
+
password: "",
|
|
67
|
+
courseUrl: "",
|
|
68
|
+
moodleBaseUrl: "https://ilearning.cycu.edu.tw",
|
|
69
|
+
headless: !options.headed,
|
|
70
|
+
slowMo: 0,
|
|
71
|
+
authStatePath: sessionPath,
|
|
72
|
+
ollamaBaseUrl: "",
|
|
73
|
+
};
|
|
74
|
+
log.info("啟動瀏覽器...");
|
|
75
|
+
const { browser, context, page } = await (0, auth_js_1.launchAuthenticated)(config, log);
|
|
76
|
+
try {
|
|
77
|
+
const session = await (0, session_js_1.extractSessionInfo)(page, config, log);
|
|
78
|
+
return { log, page, session, browser, context };
|
|
79
|
+
}
|
|
80
|
+
catch (err) {
|
|
81
|
+
await context.close();
|
|
82
|
+
await browser.close();
|
|
83
|
+
throw err;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
videosCmd
|
|
87
|
+
.command("list")
|
|
88
|
+
.description("List videos in a course")
|
|
89
|
+
.argument("<course-id>", "Course ID")
|
|
90
|
+
.option("--incomplete-only", "Show only incomplete videos")
|
|
91
|
+
.option("--output <format>", "Output format: json|csv|table|silent")
|
|
92
|
+
.action(async (courseId, options, command) => {
|
|
93
|
+
const output = getOutputFormat(command);
|
|
94
|
+
// Try pure API mode (no browser, fast!)
|
|
95
|
+
const apiContext = await createApiContext(options, command);
|
|
96
|
+
if (apiContext) {
|
|
97
|
+
try {
|
|
98
|
+
const videos = await (0, moodle_js_1.getSupervideosInCourseApi)(apiContext.session, parseInt(courseId, 10));
|
|
99
|
+
// Filter by incomplete only if requested (API returns all, no completion status)
|
|
100
|
+
// Note: API doesn't provide completion status, so --incomplete-only won't work in API mode
|
|
101
|
+
if (options.incompleteOnly) {
|
|
102
|
+
apiContext.log.warn("--incomplete-only is not supported in API mode, showing all videos");
|
|
103
|
+
}
|
|
104
|
+
(0, index_js_1.formatAndOutput)(videos, output, apiContext.log);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
catch (e) {
|
|
108
|
+
// API failed, fall through to browser mode
|
|
109
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
110
|
+
console.error(`// API mode failed: ${msg}, trying browser mode...`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
// Fallback to browser mode
|
|
114
|
+
const context = await createSessionContext(options, command);
|
|
115
|
+
if (!context) {
|
|
116
|
+
process.exitCode = 1;
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const { log, page, session, browser, context: browserContext } = context;
|
|
120
|
+
try {
|
|
121
|
+
const videos = await (0, moodle_js_1.getSupervideosInCourse)(page, session, parseInt(courseId, 10), log, {
|
|
122
|
+
incompleteOnly: options.incompleteOnly,
|
|
123
|
+
});
|
|
124
|
+
(0, index_js_1.formatAndOutput)(videos, output, log);
|
|
125
|
+
}
|
|
126
|
+
finally {
|
|
127
|
+
await (0, auth_js_2.closeBrowserSafely)(browser, browserContext);
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
videosCmd
|
|
131
|
+
.command("complete")
|
|
132
|
+
.description("Complete videos in a course")
|
|
133
|
+
.argument("<course-id>", "Course ID")
|
|
134
|
+
.option("--dry-run", "Discover videos but don't complete them")
|
|
135
|
+
.option("--output <format>", "Output format: json|csv|table|silent")
|
|
136
|
+
.action(async (courseId, options, command) => {
|
|
137
|
+
const context = await createSessionContext(options, command);
|
|
138
|
+
if (!context) {
|
|
139
|
+
process.exitCode = 1;
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
const { log, page, session, browser, context: browserContext } = context;
|
|
143
|
+
const output = getOutputFormat(command);
|
|
144
|
+
try {
|
|
145
|
+
const videos = await (0, moodle_js_1.getSupervideosInCourse)(page, session, parseInt(courseId, 10), log, {
|
|
146
|
+
incompleteOnly: true, // Only operate on incomplete videos
|
|
147
|
+
});
|
|
148
|
+
if (videos.length === 0) {
|
|
149
|
+
log.info("所有影片已完成(或無影片)。");
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
const results = [];
|
|
153
|
+
for (const sv of videos) {
|
|
154
|
+
log.info(`處理中: ${sv.name}`);
|
|
155
|
+
try {
|
|
156
|
+
const video = await (0, moodle_js_1.getVideoMetadata)(page, sv.url, log);
|
|
157
|
+
if (options.dryRun) {
|
|
158
|
+
log.info(` [試執行] viewId=${video.viewId}, duration=${video.duration}s`);
|
|
159
|
+
results.push({ name: sv.name, success: true });
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
const success = await (0, moodle_js_1.completeVideo)(page, session, { ...video, cmid: sv.cmid }, log);
|
|
163
|
+
if (success) {
|
|
164
|
+
log.success(` 已完成!`);
|
|
165
|
+
results.push({ name: sv.name, success: true });
|
|
166
|
+
}
|
|
167
|
+
else {
|
|
168
|
+
log.error(` 失敗。`);
|
|
169
|
+
results.push({ name: sv.name, success: false, error: "Failed to complete" });
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
catch (err) {
|
|
173
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
174
|
+
log.error(` 錯誤: ${msg}`);
|
|
175
|
+
results.push({ name: sv.name, success: false, error: msg });
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
const completed = results.filter(r => r.success).length;
|
|
179
|
+
const failed = results.filter(r => !r.success).length;
|
|
180
|
+
log.info(`\n執行結果: ${completed} 成功, ${failed} 失敗`);
|
|
181
|
+
if (output !== "silent") {
|
|
182
|
+
(0, index_js_1.formatAndOutput)(results, output, log);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
finally {
|
|
186
|
+
await (0, auth_js_2.closeBrowserSafely)(browser, browserContext);
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
videosCmd
|
|
190
|
+
.command("complete-all")
|
|
191
|
+
.description("Complete all incomplete videos across all courses")
|
|
192
|
+
.option("--dry-run", "Discover videos but don't complete them")
|
|
193
|
+
.option("--output <format>", "Output format: json|csv|table|silent")
|
|
194
|
+
.action(async (options, command) => {
|
|
195
|
+
const context = await createSessionContext(options, command);
|
|
196
|
+
if (!context) {
|
|
197
|
+
process.exitCode = 1;
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
const { log, page, session, browser, context: browserContext } = context;
|
|
201
|
+
const output = getOutputFormat(command);
|
|
202
|
+
try {
|
|
203
|
+
const courses = await (0, moodle_js_1.getEnrolledCourses)(page, session, log);
|
|
204
|
+
const allResults = [];
|
|
205
|
+
let totalVideos = 0;
|
|
206
|
+
let totalCompleted = 0;
|
|
207
|
+
let totalFailed = 0;
|
|
208
|
+
for (const course of courses) {
|
|
209
|
+
log.info(`\n======================================`);
|
|
210
|
+
log.info(`課程: ${course.fullname}`);
|
|
211
|
+
log.info(`======================================`);
|
|
212
|
+
const videos = await (0, moodle_js_1.getSupervideosInCourse)(page, session, course.id, log);
|
|
213
|
+
if (videos.length === 0) {
|
|
214
|
+
log.info(" 所有影片已完成(或無影片)。");
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
totalVideos += videos.length;
|
|
218
|
+
for (const sv of videos) {
|
|
219
|
+
log.info(` 處理中: ${sv.name}`);
|
|
220
|
+
try {
|
|
221
|
+
const video = await (0, moodle_js_1.getVideoMetadata)(page, sv.url, log);
|
|
222
|
+
if (options.dryRun) {
|
|
223
|
+
log.info(` [試執行] viewId=${video.viewId}, duration=${video.duration}s`);
|
|
224
|
+
allResults.push({ courseName: course.fullname, name: sv.name, success: true });
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
const success = await (0, moodle_js_1.completeVideo)(page, session, { ...video, cmid: sv.cmid }, log);
|
|
228
|
+
if (success) {
|
|
229
|
+
log.success(` 已完成!`);
|
|
230
|
+
allResults.push({ courseName: course.fullname, name: sv.name, success: true });
|
|
231
|
+
totalCompleted++;
|
|
232
|
+
}
|
|
233
|
+
else {
|
|
234
|
+
log.error(` 失敗。`);
|
|
235
|
+
allResults.push({ courseName: course.fullname, name: sv.name, success: false, error: "Failed to complete" });
|
|
236
|
+
totalFailed++;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
catch (err) {
|
|
240
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
241
|
+
log.error(` 錯誤: ${msg}`);
|
|
242
|
+
allResults.push({ courseName: course.fullname, name: sv.name, success: false, error: msg });
|
|
243
|
+
totalFailed++;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
log.info("\n===== 執行結果 =====");
|
|
248
|
+
log.info(`掃描課程數: ${courses.length}`);
|
|
249
|
+
log.info(`掃描影片數: ${totalVideos}`);
|
|
250
|
+
log.info(`執行影片數: ${totalCompleted}`);
|
|
251
|
+
if (totalFailed > 0)
|
|
252
|
+
log.warn(`失敗影片數: ${totalFailed}`);
|
|
253
|
+
if (output !== "silent") {
|
|
254
|
+
(0, index_js_1.formatAndOutput)(allResults, output, log);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
finally {
|
|
258
|
+
await (0, auth_js_2.closeBrowserSafely)(browser, browserContext);
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
// Helper function to sanitize filename
|
|
262
|
+
function sanitizeFilename(name) {
|
|
263
|
+
// Remove/replace invalid characters
|
|
264
|
+
return name
|
|
265
|
+
.replace(/[<>:"/\\|?*]/g, "_") // Replace invalid chars with underscore
|
|
266
|
+
.replace(/\s+/g, "_") // Replace spaces with underscores
|
|
267
|
+
.substring(0, 200); // Limit length
|
|
268
|
+
}
|
|
269
|
+
videosCmd
|
|
270
|
+
.command("download")
|
|
271
|
+
.description("Download videos from a course")
|
|
272
|
+
.argument("<course-id>", "Course ID")
|
|
273
|
+
.option("--output-dir <path>", "Output directory", "./downloads/videos")
|
|
274
|
+
.option("--incomplete-only", "Download only incomplete videos")
|
|
275
|
+
.action(async (courseId, options, command) => {
|
|
276
|
+
const context = await createSessionContext(options, command);
|
|
277
|
+
if (!context) {
|
|
278
|
+
process.exitCode = 1;
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
const { log, page, session, browser, context: browserContext } = context;
|
|
282
|
+
try {
|
|
283
|
+
const videos = await (0, moodle_js_1.getSupervideosInCourse)(page, session, parseInt(courseId, 10), log, {
|
|
284
|
+
incompleteOnly: options.incompleteOnly,
|
|
285
|
+
});
|
|
286
|
+
log.info(`找到 ${videos.length} 個影片`);
|
|
287
|
+
// Create output directory
|
|
288
|
+
const baseDir = (0, utils_js_1.getBaseDir)();
|
|
289
|
+
const outputDir = path_1.default.resolve(baseDir, options.outputDir);
|
|
290
|
+
fs_1.default.mkdirSync(outputDir, { recursive: true });
|
|
291
|
+
const downloaded = [];
|
|
292
|
+
for (const video of videos) {
|
|
293
|
+
const filename = sanitizeFilename(video.name) + ".mp4";
|
|
294
|
+
const outputPath = path_1.default.join(outputDir, filename);
|
|
295
|
+
log.info(`處理中: ${video.name}`);
|
|
296
|
+
try {
|
|
297
|
+
const metadata = await (0, moodle_js_1.getVideoMetadata)(page, video.url, log);
|
|
298
|
+
const result = await (0, moodle_js_1.downloadVideo)(page, metadata, outputPath, log);
|
|
299
|
+
if (result.success) {
|
|
300
|
+
log.success(` 已下載: ${result.path}`);
|
|
301
|
+
downloaded.push({ name: video.name, path: result.path, success: true, type: result.type });
|
|
302
|
+
}
|
|
303
|
+
else {
|
|
304
|
+
log.warn(` 失敗: ${result.error}`);
|
|
305
|
+
downloaded.push({ name: video.name, path: "", success: false, error: result.error, type: result.type });
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
catch (err) {
|
|
309
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
310
|
+
log.error(` 錯誤: ${msg}`);
|
|
311
|
+
downloaded.push({ name: video.name, path: "", success: false, error: msg });
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
const completed = downloaded.filter(d => d.success).length;
|
|
315
|
+
const failed = downloaded.filter(d => !d.success).length;
|
|
316
|
+
log.info(`\n執行結果: ${completed} 成功, ${failed} 失敗`);
|
|
317
|
+
const output = {
|
|
318
|
+
status: "success",
|
|
319
|
+
timestamp: new Date().toISOString(),
|
|
320
|
+
course_id: courseId,
|
|
321
|
+
output_dir: outputDir,
|
|
322
|
+
total_videos: videos.length,
|
|
323
|
+
downloaded: completed,
|
|
324
|
+
failed,
|
|
325
|
+
videos: downloaded,
|
|
326
|
+
};
|
|
327
|
+
console.log(JSON.stringify(output));
|
|
328
|
+
}
|
|
329
|
+
finally {
|
|
330
|
+
await (0, auth_js_2.closeBrowserSafely)(browser, browserContext);
|
|
331
|
+
}
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
exports.registerVideosCommand = registerVideosCommand;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import "../_dnt.polyfills.js";
|
|
3
|
+
import { createLogger } from "./lib/logger.js";
|
|
4
|
+
import type { AppConfig, Logger, SessionInfo, OutputFormat } from "./lib/types.js";
|
|
5
|
+
/**
|
|
6
|
+
* Create a session context for commands that need authentication.
|
|
7
|
+
*/
|
|
8
|
+
export declare function createSessionContext(options: {
|
|
9
|
+
config?: string;
|
|
10
|
+
session?: string;
|
|
11
|
+
verbose?: boolean;
|
|
12
|
+
silent?: boolean;
|
|
13
|
+
headed?: boolean;
|
|
14
|
+
interactive?: boolean;
|
|
15
|
+
}): Promise<{
|
|
16
|
+
config: AppConfig;
|
|
17
|
+
log: Logger;
|
|
18
|
+
page: import("playwright-core").Page;
|
|
19
|
+
session: SessionInfo;
|
|
20
|
+
} | null>;
|
|
21
|
+
/**
|
|
22
|
+
* Helper to output formatted data.
|
|
23
|
+
* For JSON output (agent mode), exits immediately after output.
|
|
24
|
+
*/
|
|
25
|
+
export declare function formatAndOutput<T extends Record<string, unknown>>(data: T | T[], format: OutputFormat, log: Logger): void;
|
|
26
|
+
export { createLogger, type AppConfig, type Logger, type SessionInfo, type OutputFormat };
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
4
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
5
|
+
};
|
|
6
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
|
+
exports.createLogger = exports.formatAndOutput = exports.createSessionContext = void 0;
|
|
8
|
+
require("../_dnt.polyfills.js");
|
|
9
|
+
const utils_js_1 = require("./lib/utils.js");
|
|
10
|
+
const commander_1 = require("commander");
|
|
11
|
+
const config_js_1 = require("./lib/config.js");
|
|
12
|
+
const auth_js_1 = require("./lib/auth.js");
|
|
13
|
+
const session_js_1 = require("./lib/session.js");
|
|
14
|
+
const logger_js_1 = require("./lib/logger.js");
|
|
15
|
+
Object.defineProperty(exports, "createLogger", { enumerable: true, get: function () { return logger_js_1.createLogger; } });
|
|
16
|
+
const deno_js_1 = __importDefault(require("../deno.js"));
|
|
17
|
+
// Import command handlers
|
|
18
|
+
const courses_js_1 = require("./commands/courses.js");
|
|
19
|
+
const videos_js_1 = require("./commands/videos.js");
|
|
20
|
+
const quizzes_js_1 = require("./commands/quizzes.js");
|
|
21
|
+
const auth_js_2 = require("./commands/auth.js");
|
|
22
|
+
const materials_js_1 = require("./commands/materials.js");
|
|
23
|
+
const grades_js_1 = require("./commands/grades.js");
|
|
24
|
+
const forums_js_1 = require("./commands/forums.js");
|
|
25
|
+
const announcements_js_1 = require("./commands/announcements.js");
|
|
26
|
+
const calendar_js_1 = require("./commands/calendar.js");
|
|
27
|
+
const skills_js_1 = require("./commands/skills.js");
|
|
28
|
+
const program = new commander_1.Command();
|
|
29
|
+
program
|
|
30
|
+
.name("openape")
|
|
31
|
+
.description(deno_js_1.default.description)
|
|
32
|
+
.version(deno_js_1.default.version);
|
|
33
|
+
// Global options
|
|
34
|
+
program
|
|
35
|
+
.option("--config <path>", "Custom config file path")
|
|
36
|
+
.option("--session <path>", "Session file path", ".auth/storage-state.json")
|
|
37
|
+
.option("--output <format>", "Output format: json|csv|table|silent", "json")
|
|
38
|
+
.option("--verbose", "Enable debug logging")
|
|
39
|
+
.option("--silent", "Suppress all log output (JSON only)")
|
|
40
|
+
.option("--headed", "Run browser in visible mode");
|
|
41
|
+
// Register subcommands
|
|
42
|
+
(0, auth_js_2.registerAuthCommand)(program);
|
|
43
|
+
(0, courses_js_1.registerCoursesCommand)(program);
|
|
44
|
+
(0, videos_js_1.registerVideosCommand)(program);
|
|
45
|
+
(0, quizzes_js_1.registerQuizzesCommand)(program);
|
|
46
|
+
(0, materials_js_1.registerMaterialsCommand)(program);
|
|
47
|
+
(0, grades_js_1.registerGradesCommand)(program);
|
|
48
|
+
(0, forums_js_1.registerForumsCommand)(program);
|
|
49
|
+
(0, announcements_js_1.registerAnnouncementsCommand)(program);
|
|
50
|
+
(0, calendar_js_1.registerCalendarCommand)(program);
|
|
51
|
+
(0, skills_js_1.registerSkillsCommand)(program);
|
|
52
|
+
/**
|
|
53
|
+
* Load configuration and authenticate, returning the context for commands.
|
|
54
|
+
*/
|
|
55
|
+
async function createCommandContext(options) {
|
|
56
|
+
const log = (0, logger_js_1.createLogger)(options.verbose, options.silent);
|
|
57
|
+
const baseDir = (0, utils_js_1.getBaseDir)();
|
|
58
|
+
const config = (0, config_js_1.loadConfig)(baseDir);
|
|
59
|
+
// Apply CLI overrides
|
|
60
|
+
if (options.headed)
|
|
61
|
+
config.headless = false;
|
|
62
|
+
if (options.session)
|
|
63
|
+
config.authStatePath = options.session;
|
|
64
|
+
return { config, log };
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Create a session context for commands that need authentication.
|
|
68
|
+
*/
|
|
69
|
+
async function createSessionContext(options) {
|
|
70
|
+
const context = await createCommandContext(options);
|
|
71
|
+
if (!context)
|
|
72
|
+
return null;
|
|
73
|
+
const { config, log } = context;
|
|
74
|
+
log.info("啟動瀏覽器...");
|
|
75
|
+
const { browser, context: browserContext, page, wsToken } = await (0, auth_js_1.launchAuthenticated)(config, log);
|
|
76
|
+
try {
|
|
77
|
+
const session = await (0, session_js_1.extractSessionInfo)(page, config, log, wsToken);
|
|
78
|
+
// Keep the browser context alive for the duration of the command
|
|
79
|
+
// Note: Caller is responsible for closing the browser
|
|
80
|
+
return { config, log, page, session };
|
|
81
|
+
}
|
|
82
|
+
catch (err) {
|
|
83
|
+
await browserContext.close();
|
|
84
|
+
await browser.close();
|
|
85
|
+
throw err;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
exports.createSessionContext = createSessionContext;
|
|
89
|
+
/**
|
|
90
|
+
* Helper to output formatted data.
|
|
91
|
+
* For JSON output (agent mode), exits immediately after output.
|
|
92
|
+
*/
|
|
93
|
+
function formatAndOutput(data, format, log) {
|
|
94
|
+
if (format === "json") {
|
|
95
|
+
console.log(JSON.stringify(data));
|
|
96
|
+
// Exit immediately for AI agent - no need to wait for browser cleanup
|
|
97
|
+
process.exit(0);
|
|
98
|
+
}
|
|
99
|
+
else if (format === "csv") {
|
|
100
|
+
const arr = Array.isArray(data) ? data : [data];
|
|
101
|
+
if (arr.length === 0)
|
|
102
|
+
return;
|
|
103
|
+
const fields = Object.keys(arr[0]);
|
|
104
|
+
console.log(formatAsCsv(arr, fields));
|
|
105
|
+
}
|
|
106
|
+
else if (format === "table") {
|
|
107
|
+
const arr = Array.isArray(data) ? data : [data];
|
|
108
|
+
if (arr.length === 0) {
|
|
109
|
+
console.log("No data");
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
console.log(formatAsTable(arr));
|
|
113
|
+
}
|
|
114
|
+
// "silent" produces no output
|
|
115
|
+
}
|
|
116
|
+
exports.formatAndOutput = formatAndOutput;
|
|
117
|
+
function formatAsCsv(data, fields) {
|
|
118
|
+
const headers = fields.join(",");
|
|
119
|
+
const rows = data.map((item) => {
|
|
120
|
+
return fields.map((field) => {
|
|
121
|
+
const value = item[field];
|
|
122
|
+
if (value === null || value === undefined)
|
|
123
|
+
return "";
|
|
124
|
+
if (typeof value === "string") {
|
|
125
|
+
if (value.includes(",") || value.includes('"') || value.includes("\n")) {
|
|
126
|
+
return `"${value.replace(/"/g, '""')}"`;
|
|
127
|
+
}
|
|
128
|
+
return value;
|
|
129
|
+
}
|
|
130
|
+
return String(value);
|
|
131
|
+
}).join(",");
|
|
132
|
+
});
|
|
133
|
+
return [headers, ...rows].join("\n");
|
|
134
|
+
}
|
|
135
|
+
function formatAsTable(data) {
|
|
136
|
+
const allFields = Array.from(new Set(data.flatMap((item) => Object.keys(item))));
|
|
137
|
+
const widths = {};
|
|
138
|
+
allFields.forEach((field) => {
|
|
139
|
+
widths[field] = Math.max(field.length, ...data.map((item) => String(item[field] ?? "").length)) + 2;
|
|
140
|
+
});
|
|
141
|
+
const header = allFields.map((f) => f.padEnd(widths[f])).join(" | ");
|
|
142
|
+
const separator = allFields.map((f) => "-".repeat(widths[f] - 1)).join("-+-");
|
|
143
|
+
const rows = data.map((item) => {
|
|
144
|
+
return allFields.map((f) => String(item[f] ?? "").padEnd(widths[f])).join(" | ");
|
|
145
|
+
});
|
|
146
|
+
return [header, separator, ...rows].join("\n");
|
|
147
|
+
}
|
|
148
|
+
// Run the program
|
|
149
|
+
if ((require.main === module)) {
|
|
150
|
+
// If no subcommand provided, show help
|
|
151
|
+
const args = process.argv.slice(2);
|
|
152
|
+
if (args.length === 0) {
|
|
153
|
+
program.help();
|
|
154
|
+
}
|
|
155
|
+
program.parse();
|
|
156
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { type Browser, type BrowserContext, type Page } from "playwright-core";
|
|
2
|
+
import type { AppConfig, Logger } from "./types.js";
|
|
3
|
+
/**
|
|
4
|
+
* Find a Chromium-based browser executable on Windows.
|
|
5
|
+
* Priority: Edge → Chrome → Brave
|
|
6
|
+
*/
|
|
7
|
+
export declare function findEdgePath(): string;
|
|
8
|
+
/**
|
|
9
|
+
* Launch a browser and return an authenticated context.
|
|
10
|
+
* Tries to restore a saved session first; falls back to fresh OAuth login.
|
|
11
|
+
* Also acquires Moodle Web Service Token for API calls.
|
|
12
|
+
*/
|
|
13
|
+
export declare function launchAuthenticated(config: AppConfig, log: Logger): Promise<{
|
|
14
|
+
browser: Browser;
|
|
15
|
+
context: BrowserContext;
|
|
16
|
+
page: Page;
|
|
17
|
+
wsToken?: string;
|
|
18
|
+
}>;
|
|
19
|
+
/**
|
|
20
|
+
* Safely close browser and context with timeout.
|
|
21
|
+
* Designed for AI agent usage - no human interaction needed.
|
|
22
|
+
* If noWait is true, initiates cleanup but doesn't wait for completion.
|
|
23
|
+
*/
|
|
24
|
+
export declare function closeBrowserSafely(browser: Browser, context?: BrowserContext, timeoutMs?: number, noWait?: boolean): Promise<void>;
|