@mo7yw4ng/openape 1.0.2 → 1.0.3
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 +8 -5
- package/esm/deno.js +1 -1
- package/esm/src/commands/auth.d.ts +1 -1
- package/esm/src/commands/auth.d.ts.map +1 -1
- package/esm/src/commands/auth.js +3 -7
- package/esm/src/commands/forums.d.ts.map +1 -1
- package/esm/src/commands/forums.js +11 -16
- package/esm/src/commands/materials.d.ts.map +1 -1
- package/esm/src/commands/materials.js +171 -11
- package/esm/src/commands/videos.d.ts.map +1 -1
- package/esm/src/commands/videos.js +108 -75
- package/esm/src/index.js +2 -2
- package/esm/src/lib/moodle.d.ts +47 -1
- package/esm/src/lib/moodle.d.ts.map +1 -1
- package/esm/src/lib/moodle.js +169 -26
- package/esm/src/lib/types.d.ts +1 -0
- package/esm/src/lib/types.d.ts.map +1 -1
- package/esm/src/lib/utils.d.ts +20 -0
- package/esm/src/lib/utils.d.ts.map +1 -1
- package/esm/src/lib/utils.js +34 -3
- package/package.json +1 -2
- package/script/deno.js +1 -1
- package/script/src/commands/auth.d.ts +1 -1
- package/script/src/commands/auth.d.ts.map +1 -1
- package/script/src/commands/auth.js +4 -8
- package/script/src/commands/forums.d.ts.map +1 -1
- package/script/src/commands/forums.js +11 -16
- package/script/src/commands/materials.d.ts.map +1 -1
- package/script/src/commands/materials.js +173 -13
- package/script/src/commands/videos.d.ts.map +1 -1
- package/script/src/commands/videos.js +109 -76
- package/script/src/index.js +1 -1
- package/script/src/lib/moodle.d.ts +47 -1
- package/script/src/lib/moodle.d.ts.map +1 -1
- package/script/src/lib/moodle.js +172 -26
- package/script/src/lib/types.d.ts +1 -0
- package/script/src/lib/types.d.ts.map +1 -1
- package/script/src/lib/utils.d.ts +20 -0
- package/script/src/lib/utils.d.ts.map +1 -1
- package/script/src/lib/utils.js +37 -3
- package/skills/openape/SKILL.md +6 -26
package/README.md
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
|
|
1
|
+
<h1 align="center">
|
|
2
|
+
<img src="assets/logo.svg" width="150" /><br/>
|
|
3
|
+
OpenApe CLI (Unofficial)
|
|
4
|
+
</h1>
|
|
2
5
|
|
|
3
6
|
中原大學 [i-Learning](https://ilearning.cycu.edu.tw/) (Moodle) 平台自動化命令列工具 (CLI),幫助你快速查詢課程、觀看影片、下載教材。
|
|
4
7
|
|
|
@@ -6,7 +9,7 @@
|
|
|
6
9
|
- 📚 **課程資訊**:列出修課清單、成績、課程大綱與進度。
|
|
7
10
|
- 📺 **影片輔助**:列出/下載影片,甚至自動標記為已觀看。
|
|
8
11
|
- 📝 **測驗與教材**:快速查看測驗、下載教材。
|
|
9
|
-
- 💬
|
|
12
|
+
- 💬 **討論區與公告**:閱讀公告與討論區。
|
|
10
13
|
- 📅 **行事曆**:內建行事曆事件查詢與匯出。
|
|
11
14
|
- 🤖 **Agent Skills**:提供 Claude Code 等 Skills 工作流支援。
|
|
12
15
|
|
|
@@ -33,8 +36,8 @@ deno install -A -g -n openape jsr:@openape/openape
|
|
|
33
36
|
第一次使用需要登入,會開啟瀏覽器讓你手動完成登入,隨後會快取 Session 供未來使用。
|
|
34
37
|
```bash
|
|
35
38
|
openape login # 登入並儲存 session (開啟瀏覽器)
|
|
36
|
-
openape
|
|
37
|
-
openape
|
|
39
|
+
openape status # 檢查當前登入狀態
|
|
40
|
+
openape logout # 登出並清除 session
|
|
38
41
|
```
|
|
39
42
|
|
|
40
43
|
### 課程 (Courses)
|
|
@@ -42,7 +45,7 @@ openape auth logout # 登出並清除 session
|
|
|
42
45
|
openape courses list # 列出所有課程 (支援 --incomplete-only, --level)
|
|
43
46
|
openape courses info <id> # 顯示特定課程的詳細資訊
|
|
44
47
|
openape courses progress <id> # 顯示特定課程的進度
|
|
45
|
-
openape courses syllabus <id> # 顯示課程大綱
|
|
48
|
+
openape courses syllabus <id> # 顯示課程大綱
|
|
46
49
|
```
|
|
47
50
|
|
|
48
51
|
### 影片 (Videos)
|
package/esm/deno.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../../../src/src/commands/auth.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAUpC,wBAAgB,
|
|
1
|
+
{"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../../../src/src/commands/auth.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAUpC,wBAAgB,eAAe,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CAoQtD"}
|
package/esm/src/commands/auth.js
CHANGED
|
@@ -5,10 +5,7 @@ import { findEdgePath } from "../lib/auth.js";
|
|
|
5
5
|
import { saveSesskey, acquireWsToken, saveWsToken } from "../lib/token.js";
|
|
6
6
|
import path from "node:path";
|
|
7
7
|
import fs from "node:fs";
|
|
8
|
-
export function
|
|
9
|
-
const authCmd = program.command("auth");
|
|
10
|
-
authCmd.description("Authentication commands");
|
|
11
|
-
// Register login directly on program (not under auth subcommand)
|
|
8
|
+
export function registerCommand(program) {
|
|
12
9
|
program
|
|
13
10
|
.command("login")
|
|
14
11
|
.description("Login to iLearning manually and save session")
|
|
@@ -186,8 +183,7 @@ export function registerAuthCommand(program) {
|
|
|
186
183
|
await new Promise(resolve => setTimeout(resolve, 500));
|
|
187
184
|
}
|
|
188
185
|
});
|
|
189
|
-
|
|
190
|
-
authCmd
|
|
186
|
+
program
|
|
191
187
|
.command("status")
|
|
192
188
|
.description("Check session status")
|
|
193
189
|
.option("--session <path>", "Session file path", ".auth/storage-state.json")
|
|
@@ -236,7 +232,7 @@ export function registerAuthCommand(program) {
|
|
|
236
232
|
console.log(JSON.stringify(result, null, 2));
|
|
237
233
|
}
|
|
238
234
|
});
|
|
239
|
-
|
|
235
|
+
program
|
|
240
236
|
.command("logout")
|
|
241
237
|
.description("Remove saved session")
|
|
242
238
|
.option("--session <path>", "Session file path", ".auth/storage-state.json")
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"forums.d.ts","sourceRoot":"","sources":["../../../src/src/commands/forums.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;
|
|
1
|
+
{"version":3,"file":"forums.d.ts","sourceRoot":"","sources":["../../../src/src/commands/forums.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAkBpC,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CA6P5D"}
|
|
@@ -68,6 +68,7 @@ export function registerForumsCommand(program) {
|
|
|
68
68
|
cmid: wsForum.cmid.toString(),
|
|
69
69
|
forum_id: wsForum.id,
|
|
70
70
|
name: wsForum.name,
|
|
71
|
+
timemodified: wsForum.timemodified,
|
|
71
72
|
// url: `https://ilearning.cycu.edu.tw/mod/forum/view.php?id=${wsForum.cmid}`,
|
|
72
73
|
});
|
|
73
74
|
}
|
|
@@ -111,7 +112,7 @@ export function registerForumsCommand(program) {
|
|
|
111
112
|
cmid: wsForum.cmid.toString(),
|
|
112
113
|
forum_id: wsForum.id,
|
|
113
114
|
name: wsForum.name,
|
|
114
|
-
|
|
115
|
+
timemodified: wsForum.timemodified,
|
|
115
116
|
});
|
|
116
117
|
}
|
|
117
118
|
}
|
|
@@ -131,7 +132,6 @@ export function registerForumsCommand(program) {
|
|
|
131
132
|
.description("List discussions in a forum (use forum ID)")
|
|
132
133
|
.argument("<forum-id>", "Forum ID")
|
|
133
134
|
.option("--output <format>", "Output format: json|csv|table|silent")
|
|
134
|
-
.option("--msg", "Include message content in response")
|
|
135
135
|
.action(async (forumId, options, command) => {
|
|
136
136
|
const apiContext = await createApiContext(options, command);
|
|
137
137
|
if (!apiContext) {
|
|
@@ -162,20 +162,15 @@ export function registerForumsCommand(program) {
|
|
|
162
162
|
forum_name: targetForum.name,
|
|
163
163
|
course_id: course?.id,
|
|
164
164
|
course_name: course?.fullname,
|
|
165
|
-
discussions: discussions.map(d => {
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
if (options.msg) {
|
|
175
|
-
discussion.message = stripHtmlTags(d.message || "");
|
|
176
|
-
}
|
|
177
|
-
return discussion;
|
|
178
|
-
}),
|
|
165
|
+
discussions: discussions.map(d => ({
|
|
166
|
+
id: d.id,
|
|
167
|
+
name: d.name,
|
|
168
|
+
user_id: d.userId,
|
|
169
|
+
time_modified: d.timeModified,
|
|
170
|
+
post_count: d.postCount,
|
|
171
|
+
unread: d.unread,
|
|
172
|
+
message: (stripHtmlTags(d.message || "")).substring(0, 250) + "...",
|
|
173
|
+
})),
|
|
179
174
|
summary: {
|
|
180
175
|
total_discussions: discussions.length,
|
|
181
176
|
},
|
|
@@ -1 +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;
|
|
1
|
+
{"version":3,"file":"materials.d.ts","sourceRoot":"","sources":["../../../src/src/commands/materials.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAgCpC,wBAAgB,wBAAwB,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CAqlB/D"}
|
|
@@ -1,20 +1,16 @@
|
|
|
1
|
-
import { getBaseDir } from "../lib/utils.js";
|
|
2
|
-
import { getEnrolledCourses, getEnrolledCoursesApi, getResourcesByCoursesApi } from "../lib/moodle.js";
|
|
1
|
+
import { getBaseDir, getOutputFormat, sanitizeFilename } from "../lib/utils.js";
|
|
2
|
+
import { getEnrolledCourses, getEnrolledCoursesApi, getResourcesByCoursesApi, updateActivityCompletionStatusManually, getSiteInfoApi, moodleApiCall } from "../lib/moodle.js";
|
|
3
3
|
import { createLogger } from "../lib/logger.js";
|
|
4
4
|
import { launchAuthenticated } from "../lib/auth.js";
|
|
5
5
|
import { extractSessionInfo } from "../lib/session.js";
|
|
6
6
|
import { closeBrowserSafely } from "../lib/auth.js";
|
|
7
|
+
import { formatAndOutput } from "../index.js";
|
|
7
8
|
import { loadWsToken } from "../lib/token.js";
|
|
8
9
|
import path from "node:path";
|
|
9
10
|
import fs from "node:fs";
|
|
10
11
|
export function registerMaterialsCommand(program) {
|
|
11
12
|
const materialsCmd = program.command("materials");
|
|
12
13
|
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
14
|
// Pure API context - no browser required (fast!)
|
|
19
15
|
async function createApiContext(options, command) {
|
|
20
16
|
const opts = command?.optsWithGlobals ? command.optsWithGlobals() : options;
|
|
@@ -77,10 +73,6 @@ export function registerMaterialsCommand(program) {
|
|
|
77
73
|
throw err;
|
|
78
74
|
}
|
|
79
75
|
}
|
|
80
|
-
// Helper to sanitize filenames
|
|
81
|
-
function sanitizeFilename(name) {
|
|
82
|
-
return name.replace(/[<>:"/\\|?*]/g, "_").replace(/\s+/g, "_");
|
|
83
|
-
}
|
|
84
76
|
// Helper to download a single resource
|
|
85
77
|
async function downloadResource(page, resource, outputDir, log) {
|
|
86
78
|
try {
|
|
@@ -359,4 +351,172 @@ export function registerMaterialsCommand(program) {
|
|
|
359
351
|
await closeBrowserSafely(browser, browserContext);
|
|
360
352
|
}
|
|
361
353
|
});
|
|
354
|
+
materialsCmd
|
|
355
|
+
.command("complete")
|
|
356
|
+
.description("Mark all incomplete resources (non-video) as complete in a course")
|
|
357
|
+
.argument("<course-id>", "Course ID")
|
|
358
|
+
.option("--dry-run", "Show what would be marked complete without doing it")
|
|
359
|
+
.option("--output <format>", "Output format: json|csv|table|silent")
|
|
360
|
+
.action(async (courseId, options, command) => {
|
|
361
|
+
const output = getOutputFormat(command);
|
|
362
|
+
const apiContext = await createApiContext(options, command);
|
|
363
|
+
if (!apiContext) {
|
|
364
|
+
process.exitCode = 1;
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
try {
|
|
368
|
+
const { log, session } = apiContext;
|
|
369
|
+
// Get user ID
|
|
370
|
+
const siteInfo = await getSiteInfoApi(session);
|
|
371
|
+
// Get completion status for all activities in the course
|
|
372
|
+
const completionData = await moodleApiCall(session, "core_completion_get_activities_completion_status", { courseid: parseInt(courseId, 10), userid: siteInfo.userid });
|
|
373
|
+
if (!completionData?.statuses) {
|
|
374
|
+
log.info("No activities found in this course.");
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
// Filter for resources (non-video) that have completion enabled but are not complete
|
|
378
|
+
const incompleteResources = completionData.statuses.filter((status) => {
|
|
379
|
+
// Only resources, not supervideo
|
|
380
|
+
if (status.modname === "supervideo")
|
|
381
|
+
return false;
|
|
382
|
+
// Must have completion enabled
|
|
383
|
+
if (!status.hascompletion)
|
|
384
|
+
return false;
|
|
385
|
+
// Must be incomplete
|
|
386
|
+
if (status.isoverallcomplete)
|
|
387
|
+
return false;
|
|
388
|
+
return true;
|
|
389
|
+
});
|
|
390
|
+
if (incompleteResources.length === 0) {
|
|
391
|
+
log.info("All resources are already complete (or no resources with completion tracking).");
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
log.info(`Found ${incompleteResources.length} incomplete resources to complete:`);
|
|
395
|
+
for (const resource of incompleteResources) {
|
|
396
|
+
log.info(` - ${resource.name} (cmid: ${resource.cmid})`);
|
|
397
|
+
}
|
|
398
|
+
if (options.dryRun) {
|
|
399
|
+
log.info("\n[Dry run] No changes made.");
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
// Mark each resource as complete
|
|
403
|
+
const results = [];
|
|
404
|
+
for (const resource of incompleteResources) {
|
|
405
|
+
const success = await updateActivityCompletionStatusManually(session, resource.cmid, true);
|
|
406
|
+
results.push({
|
|
407
|
+
cmid: resource.cmid,
|
|
408
|
+
name: resource.name,
|
|
409
|
+
success,
|
|
410
|
+
});
|
|
411
|
+
if (success) {
|
|
412
|
+
log.success(` ✓ Completed: ${resource.name}`);
|
|
413
|
+
}
|
|
414
|
+
else {
|
|
415
|
+
log.error(` ✗ Failed: ${resource.name}`);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
const completed = results.filter(r => r.success).length;
|
|
419
|
+
const failed = results.filter(r => !r.success).length;
|
|
420
|
+
log.info(`\n執行結果: ${completed} 成功, ${failed} 失敗`);
|
|
421
|
+
if (output !== "silent") {
|
|
422
|
+
formatAndOutput(results, output, log);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
catch (e) {
|
|
426
|
+
apiContext.log.error(`Error: ${e instanceof Error ? e.message : String(e)}`);
|
|
427
|
+
process.exitCode = 1;
|
|
428
|
+
}
|
|
429
|
+
});
|
|
430
|
+
materialsCmd
|
|
431
|
+
.command("complete-all")
|
|
432
|
+
.description("Mark all incomplete resources (non-video) as complete across all in-progress courses")
|
|
433
|
+
.option("--dry-run", "Show what would be marked complete without doing it")
|
|
434
|
+
.option("--level <type>", "Course level: in_progress (default) | all", "in_progress")
|
|
435
|
+
.option("--output <format>", "Output format: json|csv|table|silent")
|
|
436
|
+
.action(async (options, command) => {
|
|
437
|
+
const output = getOutputFormat(command);
|
|
438
|
+
const apiContext = await createApiContext(options, command);
|
|
439
|
+
if (!apiContext) {
|
|
440
|
+
process.exitCode = 1;
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
try {
|
|
444
|
+
const { log, session } = apiContext;
|
|
445
|
+
// Get user ID
|
|
446
|
+
const siteInfo = await getSiteInfoApi(session);
|
|
447
|
+
// Get all courses
|
|
448
|
+
const classification = options.level === "all" ? undefined : "inprogress";
|
|
449
|
+
const courses = await getEnrolledCoursesApi(session, { classification });
|
|
450
|
+
log.info(`Scanning ${courses.length} courses for incomplete resources...`);
|
|
451
|
+
const allResults = [];
|
|
452
|
+
let totalIncomplete = 0;
|
|
453
|
+
for (const course of courses) {
|
|
454
|
+
try {
|
|
455
|
+
// Get completion status for all activities in the course
|
|
456
|
+
const completionData = await moodleApiCall(session, "core_completion_get_activities_completion_status", { courseid: course.id, userid: siteInfo.userid });
|
|
457
|
+
if (!completionData?.statuses)
|
|
458
|
+
continue;
|
|
459
|
+
// Filter for resources (non-video) that have completion enabled but are not complete
|
|
460
|
+
const incompleteResources = completionData.statuses.filter((status) => {
|
|
461
|
+
if (status.modname === "supervideo")
|
|
462
|
+
return false;
|
|
463
|
+
if (!status.hascompletion)
|
|
464
|
+
return false;
|
|
465
|
+
if (status.isoverallcomplete)
|
|
466
|
+
return false;
|
|
467
|
+
return true;
|
|
468
|
+
});
|
|
469
|
+
if (incompleteResources.length > 0) {
|
|
470
|
+
log.info(`\n${course.fullname}: ${incompleteResources.length} incomplete resources`);
|
|
471
|
+
totalIncomplete += incompleteResources.length;
|
|
472
|
+
if (options.dryRun) {
|
|
473
|
+
for (const resource of incompleteResources) {
|
|
474
|
+
log.info(` - ${resource.name} (cmid: ${resource.cmid})`);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
else {
|
|
478
|
+
for (const resource of incompleteResources) {
|
|
479
|
+
const success = await updateActivityCompletionStatusManually(session, resource.cmid, true);
|
|
480
|
+
allResults.push({
|
|
481
|
+
courseId: course.id,
|
|
482
|
+
courseName: course.fullname,
|
|
483
|
+
cmid: resource.cmid,
|
|
484
|
+
name: resource.name,
|
|
485
|
+
success,
|
|
486
|
+
});
|
|
487
|
+
if (success) {
|
|
488
|
+
log.success(` ✓ Completed: ${resource.name}`);
|
|
489
|
+
}
|
|
490
|
+
else {
|
|
491
|
+
log.error(` ✗ Failed: ${resource.name}`);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
catch (e) {
|
|
498
|
+
log.warn(`Failed to process course ${course.fullname}: ${e}`);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
if (totalIncomplete === 0) {
|
|
502
|
+
log.info("\nAll resources are already complete (or no resources with completion tracking).");
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
if (options.dryRun) {
|
|
506
|
+
log.info(`\n[Dry run] Found ${totalIncomplete} incomplete resources across ${courses.length} courses.`);
|
|
507
|
+
log.info("Run without --dry-run to mark them as complete.");
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
const completed = allResults.filter(r => r.success).length;
|
|
511
|
+
const failed = allResults.filter(r => !r.success).length;
|
|
512
|
+
log.info(`\n執行結果: ${completed} 成功, ${failed} 失敗`);
|
|
513
|
+
if (output !== "silent") {
|
|
514
|
+
formatAndOutput(allResults, output, log);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
catch (e) {
|
|
518
|
+
apiContext.log.error(`Error: ${e instanceof Error ? e.message : String(e)}`);
|
|
519
|
+
process.exitCode = 1;
|
|
520
|
+
}
|
|
521
|
+
});
|
|
362
522
|
}
|
|
@@ -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;AAYpC,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,
|
|
1
|
+
{"version":3,"file":"videos.d.ts","sourceRoot":"","sources":["../../../src/src/commands/videos.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAYpC,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CA+X5D"}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { getBaseDir } from "../lib/utils.js";
|
|
2
|
-
import {
|
|
1
|
+
import { getBaseDir, getOutputFormat, sanitizeFilename } from "../lib/utils.js";
|
|
2
|
+
import { getEnrolledCoursesApi, getSupervideosInCourse, getSupervideosInCourseApi, getVideoMetadata, completeVideoApi, downloadVideo, getIncompleteVideosApi } from "../lib/moodle.js";
|
|
3
3
|
import { createLogger } from "../lib/logger.js";
|
|
4
4
|
import { launchAuthenticated } from "../lib/auth.js";
|
|
5
5
|
import { extractSessionInfo } from "../lib/session.js";
|
|
@@ -11,15 +11,11 @@ import fs from "node:fs";
|
|
|
11
11
|
export function registerVideosCommand(program) {
|
|
12
12
|
const videosCmd = program.command("videos");
|
|
13
13
|
videosCmd.description("Video progress operations");
|
|
14
|
-
// Helper to get output format from global or local options
|
|
15
|
-
function getOutputFormat(command) {
|
|
16
|
-
const opts = command.optsWithGlobals();
|
|
17
|
-
return opts.output || "json";
|
|
18
|
-
}
|
|
19
14
|
// Pure API context - no browser required (fast!)
|
|
20
15
|
async function createApiContext(options, command) {
|
|
21
16
|
const opts = command?.optsWithGlobals ? command.optsWithGlobals() : options;
|
|
22
|
-
|
|
17
|
+
// Don't silence logs for commands that don't have explicit output format control
|
|
18
|
+
const outputFormat = command && command.optsWithGlobals ? getOutputFormat(command) : "table";
|
|
23
19
|
const silent = outputFormat === "json" && !opts.verbose;
|
|
24
20
|
const log = createLogger(opts.verbose, silent);
|
|
25
21
|
const baseDir = getBaseDir();
|
|
@@ -91,46 +87,61 @@ export function registerVideosCommand(program) {
|
|
|
91
87
|
process.exitCode = 1;
|
|
92
88
|
return;
|
|
93
89
|
}
|
|
94
|
-
|
|
95
|
-
//
|
|
90
|
+
let videos = await getSupervideosInCourseApi(apiContext.session, parseInt(courseId, 10));
|
|
91
|
+
// Filter for incomplete videos if requested
|
|
96
92
|
if (options.incompleteOnly) {
|
|
97
|
-
|
|
93
|
+
videos = videos.filter(v => !v.isComplete);
|
|
98
94
|
}
|
|
99
95
|
formatAndOutput(videos, output, apiContext.log);
|
|
100
96
|
});
|
|
101
97
|
videosCmd
|
|
102
98
|
.command("complete")
|
|
103
|
-
.description("Complete videos in a course (
|
|
99
|
+
.description("Complete videos in a course (uses API for list & completion, browser for metadata)")
|
|
104
100
|
.argument("<course-id>", "Course ID")
|
|
105
101
|
.option("--dry-run", "Discover videos but don't complete them")
|
|
106
102
|
.option("--output <format>", "Output format: json|csv|table|silent")
|
|
107
103
|
.action(async (courseId, options, command) => {
|
|
104
|
+
const output = getOutputFormat(command);
|
|
105
|
+
// Get API context for getting incomplete videos and completion
|
|
106
|
+
const apiContext = await createApiContext(options, command);
|
|
107
|
+
if (!apiContext) {
|
|
108
|
+
process.exitCode = 1;
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
// Get incomplete videos via API (fast, no browser needed)
|
|
112
|
+
const incompleteVideos = await getIncompleteVideosApi(apiContext.session, parseInt(courseId, 10));
|
|
113
|
+
if (incompleteVideos.length === 0) {
|
|
114
|
+
apiContext.log.info("所有影片已完成(或無影片)。");
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
apiContext.log.info(`找到 ${incompleteVideos.length} 部未完成影片`);
|
|
118
|
+
// Dry-run: show videos without needing browser
|
|
119
|
+
if (options.dryRun) {
|
|
120
|
+
const results = incompleteVideos.map(v => ({ name: v.name, success: true }));
|
|
121
|
+
for (const video of incompleteVideos) {
|
|
122
|
+
apiContext.log.info(` [試執行] ${video.name}`);
|
|
123
|
+
}
|
|
124
|
+
apiContext.log.info(`\n執行結果: ${results.length} 影片將被完成`);
|
|
125
|
+
if (output !== "silent") {
|
|
126
|
+
formatAndOutput(results, output, apiContext.log);
|
|
127
|
+
}
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
// Need browser only for getting viewId and duration (not needed for dry-run)
|
|
108
131
|
const context = await createSessionContext(options, command);
|
|
109
132
|
if (!context) {
|
|
110
133
|
process.exitCode = 1;
|
|
111
134
|
return;
|
|
112
135
|
}
|
|
113
|
-
const { log, page,
|
|
114
|
-
const output = getOutputFormat(command);
|
|
136
|
+
const { log, page, browser, context: browserContext } = context;
|
|
115
137
|
try {
|
|
116
|
-
const videos = await getSupervideosInCourse(page, session, parseInt(courseId, 10), log, {
|
|
117
|
-
incompleteOnly: true, // Only operate on incomplete videos
|
|
118
|
-
});
|
|
119
|
-
if (videos.length === 0) {
|
|
120
|
-
log.info("所有影片已完成(或無影片)。");
|
|
121
|
-
return;
|
|
122
|
-
}
|
|
123
138
|
const results = [];
|
|
124
|
-
for (const sv of
|
|
139
|
+
for (const sv of incompleteVideos) {
|
|
125
140
|
log.info(`處理中: ${sv.name}`);
|
|
126
141
|
try {
|
|
127
142
|
const video = await getVideoMetadata(page, sv.url, log);
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
results.push({ name: sv.name, success: true });
|
|
131
|
-
continue;
|
|
132
|
-
}
|
|
133
|
-
const success = await completeVideo(page, session, { ...video, cmid: sv.cmid }, log);
|
|
143
|
+
// Use WS API for completion
|
|
144
|
+
const success = await completeVideoApi(apiContext.session, { ...video, cmid: sv.cmid.toString() });
|
|
134
145
|
if (success) {
|
|
135
146
|
log.success(` 已完成!`);
|
|
136
147
|
results.push({ name: sv.name, success: true });
|
|
@@ -159,65 +170,94 @@ export function registerVideosCommand(program) {
|
|
|
159
170
|
});
|
|
160
171
|
videosCmd
|
|
161
172
|
.command("complete-all")
|
|
162
|
-
.description("Complete all incomplete videos across all courses (
|
|
173
|
+
.description("Complete all incomplete videos across all courses (uses API for list & completion, browser for metadata)")
|
|
163
174
|
.option("--dry-run", "Discover videos but don't complete them")
|
|
164
175
|
.option("--output <format>", "Output format: json|csv|table|silent")
|
|
165
176
|
.action(async (options, command) => {
|
|
177
|
+
const output = getOutputFormat(command);
|
|
178
|
+
// Get API context for getting incomplete videos and completion
|
|
179
|
+
const apiContext = await createApiContext(options, command);
|
|
180
|
+
if (!apiContext) {
|
|
181
|
+
process.exitCode = 1;
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
// Get all courses via API
|
|
185
|
+
const classification = undefined; // all courses
|
|
186
|
+
const courses = await getEnrolledCoursesApi(apiContext.session, { classification });
|
|
187
|
+
apiContext.log.info(`掃描 ${courses.length} 個課程...`);
|
|
188
|
+
// Collect all incomplete videos across all courses using flatMap for cleaner code
|
|
189
|
+
const allIncompleteVideos = (await Promise.allSettled(courses.map(async (course) => {
|
|
190
|
+
try {
|
|
191
|
+
const videos = await getIncompleteVideosApi(apiContext.session, course.id);
|
|
192
|
+
return videos.map((video) => ({
|
|
193
|
+
courseId: course.id,
|
|
194
|
+
courseName: course.fullname,
|
|
195
|
+
cmid: video.cmid,
|
|
196
|
+
name: video.name,
|
|
197
|
+
url: video.url,
|
|
198
|
+
}));
|
|
199
|
+
}
|
|
200
|
+
catch (e) {
|
|
201
|
+
apiContext.log.warn(`無法取得課程 ${course.fullname} 的影片: ${e}`);
|
|
202
|
+
return [];
|
|
203
|
+
}
|
|
204
|
+
})))
|
|
205
|
+
.filter((result) => result.status === "fulfilled")
|
|
206
|
+
.flatMap((result) => result.status === "fulfilled" ? result.value : []);
|
|
207
|
+
if (allIncompleteVideos.length === 0) {
|
|
208
|
+
apiContext.log.info("所有影片已完成(或無影片)。");
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
apiContext.log.info(`找到 ${allIncompleteVideos.length} 部未完成影片`);
|
|
212
|
+
// Dry-run: show videos without needing browser
|
|
213
|
+
if (options.dryRun) {
|
|
214
|
+
for (const video of allIncompleteVideos) {
|
|
215
|
+
apiContext.log.info(` [試執行] [${video.courseName}] ${video.name}`);
|
|
216
|
+
}
|
|
217
|
+
apiContext.log.info("\n===== 執行結果 =====");
|
|
218
|
+
apiContext.log.info(`掃描課程數: ${courses.length}`);
|
|
219
|
+
apiContext.log.info(`找到未完成影片: ${allIncompleteVideos.length}`);
|
|
220
|
+
apiContext.log.info(`執行影片數: ${allIncompleteVideos.length} (試執行)`);
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
// Need browser only for getting viewId and duration (not needed for dry-run)
|
|
166
224
|
const context = await createSessionContext(options, command);
|
|
167
225
|
if (!context) {
|
|
168
226
|
process.exitCode = 1;
|
|
169
227
|
return;
|
|
170
228
|
}
|
|
171
|
-
const { log, page,
|
|
172
|
-
const output = getOutputFormat(command);
|
|
229
|
+
const { log, page, browser, context: browserContext } = context;
|
|
173
230
|
try {
|
|
174
|
-
const courses = await getEnrolledCourses(page, session, log);
|
|
175
231
|
const allResults = [];
|
|
176
|
-
let totalVideos = 0;
|
|
177
232
|
let totalCompleted = 0;
|
|
178
233
|
let totalFailed = 0;
|
|
179
|
-
for (const
|
|
180
|
-
log.info(
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
for (const sv of videos) {
|
|
190
|
-
log.info(` 處理中: ${sv.name}`);
|
|
191
|
-
try {
|
|
192
|
-
const video = await getVideoMetadata(page, sv.url, log);
|
|
193
|
-
if (options.dryRun) {
|
|
194
|
-
log.info(` [試執行] viewId=${video.viewId}, duration=${video.duration}s`);
|
|
195
|
-
allResults.push({ courseName: course.fullname, name: sv.name, success: true });
|
|
196
|
-
continue;
|
|
197
|
-
}
|
|
198
|
-
const success = await completeVideo(page, session, { ...video, cmid: sv.cmid }, log);
|
|
199
|
-
if (success) {
|
|
200
|
-
log.success(` 已完成!`);
|
|
201
|
-
allResults.push({ courseName: course.fullname, name: sv.name, success: true });
|
|
202
|
-
totalCompleted++;
|
|
203
|
-
}
|
|
204
|
-
else {
|
|
205
|
-
log.error(` 失敗。`);
|
|
206
|
-
allResults.push({ courseName: course.fullname, name: sv.name, success: false, error: "Failed to complete" });
|
|
207
|
-
totalFailed++;
|
|
208
|
-
}
|
|
234
|
+
for (const video of allIncompleteVideos) {
|
|
235
|
+
log.info(`處理中: [${video.courseName}] ${video.name}`);
|
|
236
|
+
try {
|
|
237
|
+
const metadata = await getVideoMetadata(page, video.url, log);
|
|
238
|
+
// Use WS API for completion
|
|
239
|
+
const success = await completeVideoApi(apiContext.session, { ...metadata, cmid: video.cmid.toString() });
|
|
240
|
+
if (success) {
|
|
241
|
+
log.success(` 已完成!`);
|
|
242
|
+
allResults.push({ courseName: video.courseName, name: video.name, success: true });
|
|
243
|
+
totalCompleted++;
|
|
209
244
|
}
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
allResults.push({ courseName: course.fullname, name: sv.name, success: false, error: msg });
|
|
245
|
+
else {
|
|
246
|
+
log.error(` 失敗。`);
|
|
247
|
+
allResults.push({ courseName: video.courseName, name: video.name, success: false, error: "Failed to complete" });
|
|
214
248
|
totalFailed++;
|
|
215
249
|
}
|
|
216
250
|
}
|
|
251
|
+
catch (err) {
|
|
252
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
253
|
+
log.error(` 錯誤: ${msg}`);
|
|
254
|
+
allResults.push({ courseName: video.courseName, name: video.name, success: false, error: msg });
|
|
255
|
+
totalFailed++;
|
|
256
|
+
}
|
|
217
257
|
}
|
|
218
258
|
log.info("\n===== 執行結果 =====");
|
|
219
259
|
log.info(`掃描課程數: ${courses.length}`);
|
|
220
|
-
log.info(
|
|
260
|
+
log.info(`找到未完成影片: ${allIncompleteVideos.length}`);
|
|
221
261
|
log.info(`執行影片數: ${totalCompleted}`);
|
|
222
262
|
if (totalFailed > 0)
|
|
223
263
|
log.warn(`失敗影片數: ${totalFailed}`);
|
|
@@ -229,13 +269,6 @@ export function registerVideosCommand(program) {
|
|
|
229
269
|
await closeBrowserSafely(browser, browserContext);
|
|
230
270
|
}
|
|
231
271
|
});
|
|
232
|
-
// Helper function to sanitize filename
|
|
233
|
-
function sanitizeFilename(name) {
|
|
234
|
-
return name
|
|
235
|
-
.replace(/[<>:"/\\|?*]/g, "_")
|
|
236
|
-
.replace(/\s+/g, "_")
|
|
237
|
-
.substring(0, 200);
|
|
238
|
-
}
|
|
239
272
|
videosCmd
|
|
240
273
|
.command("download")
|
|
241
274
|
.description("Download videos from a course (requires browser)")
|
package/esm/src/index.js
CHANGED
|
@@ -11,7 +11,7 @@ import denoJson from "../deno.js";
|
|
|
11
11
|
import { registerCoursesCommand } from "./commands/courses.js";
|
|
12
12
|
import { registerVideosCommand } from "./commands/videos.js";
|
|
13
13
|
import { registerQuizzesCommand } from "./commands/quizzes.js";
|
|
14
|
-
import {
|
|
14
|
+
import { registerCommand } from "./commands/auth.js";
|
|
15
15
|
import { registerMaterialsCommand } from "./commands/materials.js";
|
|
16
16
|
import { registerGradesCommand } from "./commands/grades.js";
|
|
17
17
|
import { registerForumsCommand } from "./commands/forums.js";
|
|
@@ -32,7 +32,7 @@ program
|
|
|
32
32
|
.option("--silent", "Suppress all log output (JSON only)")
|
|
33
33
|
.option("--headed", "Run browser in visible mode");
|
|
34
34
|
// Register subcommands
|
|
35
|
-
|
|
35
|
+
registerCommand(program);
|
|
36
36
|
registerCoursesCommand(program);
|
|
37
37
|
registerVideosCommand(program);
|
|
38
38
|
registerQuizzesCommand(program);
|