tronclass-auto 1.0.2 → 1.1.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/bin/tronclass.js +17 -5
- package/package.json +12 -5
- package/src/index.js +127 -46
- package/src/tui.js +235 -0
- package/src/video.js +58 -8
package/bin/tronclass.js
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
const { program } = require('commander');
|
|
4
|
-
const path = require('path');
|
|
5
|
-
|
|
6
4
|
const pkg = require('../package.json');
|
|
7
5
|
|
|
8
6
|
program
|
|
@@ -38,9 +36,17 @@ program
|
|
|
38
36
|
await run(opts);
|
|
39
37
|
});
|
|
40
38
|
|
|
39
|
+
program
|
|
40
|
+
.command('menu')
|
|
41
|
+
.description('互動式選單')
|
|
42
|
+
.action(async () => {
|
|
43
|
+
const { mainMenu } = require('../src/tui');
|
|
44
|
+
await mainMenu();
|
|
45
|
+
});
|
|
46
|
+
|
|
41
47
|
program
|
|
42
48
|
.command('status')
|
|
43
|
-
.description('
|
|
49
|
+
.description('查看各課程進度統計(含報表)')
|
|
44
50
|
.action(async () => {
|
|
45
51
|
const { showStatus } = require('../src/index');
|
|
46
52
|
await showStatus();
|
|
@@ -49,7 +55,7 @@ program
|
|
|
49
55
|
program
|
|
50
56
|
.command('course')
|
|
51
57
|
.description('管理課程列表')
|
|
52
|
-
.option('-a, --add <url>', '新增課程 URL')
|
|
58
|
+
.option('-a, --add <url>', '新增課程 ID 或 URL')
|
|
53
59
|
.option('-r, --remove <index>', '移除課程(輸入編號)')
|
|
54
60
|
.option('-l, --list', '列出所有課程')
|
|
55
61
|
.option('--clear', '清空所有課程')
|
|
@@ -66,4 +72,10 @@ program
|
|
|
66
72
|
showConfig();
|
|
67
73
|
});
|
|
68
74
|
|
|
69
|
-
|
|
75
|
+
const args = process.argv.slice(2);
|
|
76
|
+
if (args.length === 0) {
|
|
77
|
+
const { mainMenu } = require('../src/tui');
|
|
78
|
+
mainMenu().catch(() => process.exit(0));
|
|
79
|
+
} else {
|
|
80
|
+
program.parse();
|
|
81
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tronclass-auto",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "自動觀看 eclass/TronClass 影片、填寫表單的 CLI 工具",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -14,13 +14,20 @@
|
|
|
14
14
|
"scripts": {
|
|
15
15
|
"start": "node bin/tronclass.js run"
|
|
16
16
|
},
|
|
17
|
-
"keywords": [
|
|
17
|
+
"keywords": [
|
|
18
|
+
"eclass",
|
|
19
|
+
"tronclass",
|
|
20
|
+
"yuntech",
|
|
21
|
+
"automation",
|
|
22
|
+
"video"
|
|
23
|
+
],
|
|
18
24
|
"author": "",
|
|
19
25
|
"license": "MIT",
|
|
20
26
|
"dependencies": {
|
|
21
|
-
"playwright": "^1.40.0",
|
|
22
|
-
"commander": "^12.0.0",
|
|
23
27
|
"chalk": "^4.1.2",
|
|
24
|
-
"
|
|
28
|
+
"commander": "^12.0.0",
|
|
29
|
+
"inquirer": "^8.2.7",
|
|
30
|
+
"open": "^8.4.2",
|
|
31
|
+
"playwright": "^1.40.0"
|
|
25
32
|
}
|
|
26
33
|
}
|
package/src/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
const re = require;
|
|
2
2
|
const chalk = re('chalk');
|
|
3
|
-
const { loadCookies
|
|
4
|
-
const { watchVideo } = re('./video');
|
|
3
|
+
const { loadCookies } = re('./auth');
|
|
4
|
+
const { watchVideo, formatTime, printReport } = re('./video');
|
|
5
5
|
const { loadConfig, saveConfig, markDone, isDone, BASE_URL } = re('./config');
|
|
6
6
|
|
|
7
7
|
async function getUncompleted(page, courseId) {
|
|
@@ -38,57 +38,67 @@ async function getUncompleted(page, courseId) {
|
|
|
38
38
|
});
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
async function processCourse(page, courseId) {
|
|
41
|
+
async function processCourse(page, courseId, stats) {
|
|
42
42
|
let done = 0;
|
|
43
43
|
let skip = 0;
|
|
44
|
+
let fail = 0;
|
|
44
45
|
|
|
45
46
|
const uncompleted = await getUncompleted(page, courseId);
|
|
46
47
|
const videos = uncompleted.filter(u => u.type === 'online_video');
|
|
47
|
-
|
|
48
48
|
const remaining = videos.filter(v => !isDone(courseId, v.id));
|
|
49
|
-
console.log(chalk.cyan(` Uncompleted videos: ${videos.length}, already done: ${videos.length - remaining.length}, remaining: ${remaining.length}`));
|
|
50
49
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
50
|
+
console.log(chalk.cyan(` Uncompleted: ${videos.length} videos, remaining: ${remaining.length}`));
|
|
51
|
+
|
|
52
|
+
if (stats) {
|
|
53
|
+
stats.remainingVideos = remaining.length;
|
|
54
|
+
stats.estimatedTimeForRemaining = remaining.reduce((sum, v) => sum + 120, 0);
|
|
55
|
+
}
|
|
54
56
|
|
|
57
|
+
for (let i = 0; i < remaining.length; i++) {
|
|
58
|
+
const act = remaining[i];
|
|
59
|
+
console.log(chalk.white(`\n [${i + 1}/${remaining.length}] ${act.title}`));
|
|
60
|
+
|
|
61
|
+
const url = `${BASE_URL}/course/${courseId}/learning-activity/full-screen#/${act.id}`;
|
|
55
62
|
try {
|
|
56
63
|
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 15000 });
|
|
57
64
|
} catch (e) {}
|
|
58
65
|
await page.waitForTimeout(5000);
|
|
59
66
|
|
|
60
67
|
if (!page.url().includes('learning-activity')) {
|
|
61
|
-
console.log(chalk.yellow(' [WARN] Failed to load activity page
|
|
68
|
+
console.log(chalk.yellow(' [WARN] Failed to load activity page'));
|
|
62
69
|
skip++;
|
|
63
70
|
continue;
|
|
64
71
|
}
|
|
65
72
|
|
|
66
73
|
try {
|
|
67
|
-
|
|
74
|
+
if (stats) {
|
|
75
|
+
stats.remainingVideos = remaining.length - i - 1;
|
|
76
|
+
}
|
|
77
|
+
const success = await watchVideo(page, stats);
|
|
68
78
|
if (success) {
|
|
69
79
|
markDone(courseId, act.id);
|
|
70
80
|
done++;
|
|
71
|
-
console.log(chalk.green(' [OK] Saved
|
|
81
|
+
console.log(chalk.green(' [OK] Saved'));
|
|
72
82
|
} else {
|
|
73
|
-
|
|
83
|
+
fail++;
|
|
74
84
|
}
|
|
75
85
|
} catch (e) {
|
|
76
86
|
console.log(chalk.red(` [ERROR] ${e.message}`));
|
|
77
|
-
|
|
87
|
+
fail++;
|
|
78
88
|
}
|
|
79
89
|
|
|
80
90
|
await page.waitForTimeout(2000);
|
|
81
91
|
}
|
|
82
92
|
|
|
83
|
-
console.log(chalk.cyan(
|
|
84
|
-
return { done, skip };
|
|
93
|
+
console.log(chalk.cyan(` Result: ${done} watched, ${skip} skipped, ${fail} failed`));
|
|
94
|
+
return { done, skip, fail };
|
|
85
95
|
}
|
|
86
96
|
|
|
87
97
|
async function showStatus() {
|
|
88
98
|
const config = loadConfig();
|
|
89
99
|
const cookies = loadCookies();
|
|
90
100
|
if (!cookies.length) {
|
|
91
|
-
console.log(chalk.red('[ERROR] 沒有 Cookie,請先執行 tronclass login
|
|
101
|
+
console.log(chalk.red('[ERROR] 沒有 Cookie,請先執行 tronclass login'));
|
|
92
102
|
return;
|
|
93
103
|
}
|
|
94
104
|
|
|
@@ -112,27 +122,64 @@ async function showStatus() {
|
|
|
112
122
|
}
|
|
113
123
|
|
|
114
124
|
const courses = config.courses || [];
|
|
115
|
-
console.log(chalk.cyan('\n
|
|
125
|
+
console.log(chalk.cyan('\n┌' + '─'.repeat(48) + '┐'));
|
|
126
|
+
console.log(chalk.cyan('│') + chalk.bold.white(' 📋 課程進度統計' + ' '.repeat(30)) + chalk.cyan('│'));
|
|
127
|
+
console.log(chalk.cyan('├' + '─'.repeat(48) + '┤'));
|
|
128
|
+
|
|
129
|
+
let totalVideos = 0;
|
|
130
|
+
let totalRemaining = 0;
|
|
131
|
+
let totalExams = 0;
|
|
116
132
|
|
|
117
133
|
for (const courseUrl of courses) {
|
|
118
134
|
const match = courseUrl.match(/\/course\/(\d+)\//);
|
|
119
135
|
const courseId = match ? match[1] : '?';
|
|
120
136
|
try {
|
|
121
137
|
const uncompleted = await getUncompleted(page, courseId);
|
|
122
|
-
const
|
|
123
|
-
const videos = uncompleted.filter(u => u.type === 'online_video').length;
|
|
138
|
+
const videos = uncompleted.filter(u => u.type === 'online_video');
|
|
124
139
|
const exams = uncompleted.filter(u => u.type === 'exam').length;
|
|
125
|
-
const other =
|
|
126
|
-
|
|
140
|
+
const other = uncompleted.length - videos.length - exams;
|
|
141
|
+
totalVideos += videos.length;
|
|
142
|
+
totalRemaining += videos.length;
|
|
143
|
+
totalExams += exams;
|
|
144
|
+
|
|
145
|
+
const bar = generateBar(videos.length, 50);
|
|
146
|
+
console.log(chalk.cyan('│') + ` [${courseId}] ${videos.length} videos ${exams} exams`.padEnd(50) + chalk.cyan('│'));
|
|
147
|
+
console.log(chalk.cyan('│') + ` ${bar}`.padEnd(50) + chalk.cyan('│'));
|
|
127
148
|
} catch (e) {
|
|
128
|
-
console.log(chalk.
|
|
149
|
+
console.log(chalk.cyan('│') + ` [${courseId}] ERROR`.padEnd(50) + chalk.cyan('│'));
|
|
129
150
|
}
|
|
130
151
|
}
|
|
131
152
|
|
|
153
|
+
console.log(chalk.cyan('├' + '─'.repeat(48) + '┤'));
|
|
154
|
+
console.log(chalk.cyan('│') + ` 總計: ${totalRemaining} videos, ${totalExams} exams`.padEnd(50) + chalk.cyan('│'));
|
|
155
|
+
console.log(chalk.cyan('│') + ` 預估時間: ${formatTime(totalRemaining * 120)}`.padEnd(50) + chalk.cyan('│'));
|
|
156
|
+
console.log(chalk.cyan('└' + '─'.repeat(48) + '┘'));
|
|
132
157
|
console.log('');
|
|
158
|
+
|
|
133
159
|
await browser.close();
|
|
134
160
|
}
|
|
135
161
|
|
|
162
|
+
function generateBar(remaining, width) {
|
|
163
|
+
if (remaining === 0) return chalk.green('█'.repeat(width) + ' 100%');
|
|
164
|
+
const filled = Math.max(0, width - Math.min(remaining, width));
|
|
165
|
+
const empty = width - filled;
|
|
166
|
+
return chalk.green('█'.repeat(filled)) + chalk.gray('░'.repeat(empty)) + ` ${remaining} remaining`;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function launchBrowser(headless) {
|
|
170
|
+
const { chromium } = re('playwright');
|
|
171
|
+
const { ensureBrowser } = re('./browser');
|
|
172
|
+
const exePath = await ensureBrowser();
|
|
173
|
+
const launchOpts = { headless, slowMo: 50 };
|
|
174
|
+
if (exePath) launchOpts.executablePath = exePath;
|
|
175
|
+
const browser = await chromium.launch(launchOpts);
|
|
176
|
+
const context = await browser.newContext({
|
|
177
|
+
viewport: { width: 1280, height: 900 },
|
|
178
|
+
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
|
|
179
|
+
});
|
|
180
|
+
return { browser, context };
|
|
181
|
+
}
|
|
182
|
+
|
|
136
183
|
async function run(opts = {}) {
|
|
137
184
|
const config = loadConfig();
|
|
138
185
|
const cookies = loadCookies();
|
|
@@ -146,16 +193,7 @@ async function run(opts = {}) {
|
|
|
146
193
|
|
|
147
194
|
if (opts.headless) config.headless = true;
|
|
148
195
|
|
|
149
|
-
const {
|
|
150
|
-
const { ensureBrowser } = re('./browser');
|
|
151
|
-
const exePath = await ensureBrowser();
|
|
152
|
-
const launchOpts = { headless: config.headless, slowMo: config.slowMo || 50 };
|
|
153
|
-
if (exePath) launchOpts.executablePath = exePath;
|
|
154
|
-
const browser = await chromium.launch(launchOpts);
|
|
155
|
-
const context = await browser.newContext({
|
|
156
|
-
viewport: { width: 1280, height: 900 },
|
|
157
|
-
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
|
|
158
|
-
});
|
|
196
|
+
const { browser, context } = await launchBrowser(config.headless);
|
|
159
197
|
await context.addCookies(cookies);
|
|
160
198
|
const page = await context.newPage();
|
|
161
199
|
|
|
@@ -163,7 +201,7 @@ async function run(opts = {}) {
|
|
|
163
201
|
await page.waitForTimeout(2000);
|
|
164
202
|
|
|
165
203
|
if (page.url().includes('login')) {
|
|
166
|
-
console.log(chalk.red('[ERROR] Cookie 過期,請重新執行
|
|
204
|
+
console.log(chalk.red('[ERROR] Cookie 過期,請重新執行 tronclass login'));
|
|
167
205
|
await browser.close();
|
|
168
206
|
return;
|
|
169
207
|
}
|
|
@@ -171,7 +209,6 @@ async function run(opts = {}) {
|
|
|
171
209
|
console.log(chalk.green('[OK] Login verified'));
|
|
172
210
|
|
|
173
211
|
let courses = config.courses || [];
|
|
174
|
-
|
|
175
212
|
if (opts.course) {
|
|
176
213
|
const ids = opts.course.split(',').map(s => s.trim());
|
|
177
214
|
courses = courses.filter(url => ids.some(id => url.includes(id)));
|
|
@@ -182,33 +219,77 @@ async function run(opts = {}) {
|
|
|
182
219
|
|
|
183
220
|
if (!courses.length) {
|
|
184
221
|
console.log(chalk.yellow('[WARN] 沒有設定課程'));
|
|
185
|
-
console.log(chalk.yellow('
|
|
222
|
+
console.log(chalk.yellow(' 請執行 tronclass course --add <課程ID>'));
|
|
186
223
|
await browser.close();
|
|
187
224
|
return;
|
|
188
225
|
}
|
|
189
226
|
|
|
190
|
-
|
|
191
|
-
|
|
227
|
+
await _runCourses(page, courses, browser);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async function runWithSelection(courseUrls, headless) {
|
|
231
|
+
const config = loadConfig();
|
|
232
|
+
const cookies = loadCookies();
|
|
233
|
+
|
|
234
|
+
if (!cookies.length) {
|
|
235
|
+
console.log(chalk.red('[ERROR] 沒有 Cookie'));
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const { browser, context } = await launchBrowser(headless);
|
|
240
|
+
await context.addCookies(cookies);
|
|
241
|
+
const page = await context.newPage();
|
|
242
|
+
|
|
243
|
+
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded', timeout: 15000 });
|
|
244
|
+
await page.waitForTimeout(2000);
|
|
245
|
+
|
|
246
|
+
if (page.url().includes('login')) {
|
|
247
|
+
console.log(chalk.red('[ERROR] Cookie 過期'));
|
|
248
|
+
await browser.close();
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
console.log(chalk.green('[OK] Login verified\n'));
|
|
253
|
+
|
|
254
|
+
await _runCourses(page, courseUrls, browser);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async function _runCourses(page, courses, browser) {
|
|
258
|
+
const startTime = Date.now();
|
|
259
|
+
const stats = {
|
|
260
|
+
videosWatched: 0,
|
|
261
|
+
videosSkipped: 0,
|
|
262
|
+
videosFailed: 0,
|
|
263
|
+
currentVideoDuration: 0,
|
|
264
|
+
currentVideoElapsed: 0,
|
|
265
|
+
remainingVideos: 0,
|
|
266
|
+
estimatedTimeForRemaining: 0,
|
|
267
|
+
totalElapsed: 0,
|
|
268
|
+
timeSaved: 0,
|
|
269
|
+
courseResults: []
|
|
270
|
+
};
|
|
192
271
|
|
|
193
272
|
for (let i = 0; i < courses.length; i++) {
|
|
194
273
|
const courseUrl = courses[i];
|
|
195
274
|
const match = courseUrl.match(/\/course\/(\d+)\//);
|
|
196
|
-
const courseId = match ? match[1] : '
|
|
275
|
+
const courseId = match ? match[1] : '?';
|
|
197
276
|
|
|
198
277
|
console.log(chalk.cyan(`\n${'='.repeat(50)}`));
|
|
199
|
-
console.log(chalk.cyan(`Course ${i + 1}/${courses.length} (ID: ${courseId})`));
|
|
278
|
+
console.log(chalk.cyan(` Course ${i + 1}/${courses.length} (ID: ${courseId})`));
|
|
200
279
|
console.log(chalk.cyan(`${'='.repeat(50)}`));
|
|
201
280
|
|
|
202
|
-
const result = await processCourse(page, courseId);
|
|
203
|
-
|
|
204
|
-
|
|
281
|
+
const result = await processCourse(page, courseId, stats);
|
|
282
|
+
stats.courseResults.push({ id: courseId, ...result });
|
|
283
|
+
stats.videosWatched += result.done;
|
|
284
|
+
stats.videosSkipped += result.skip;
|
|
285
|
+
stats.videosFailed += result.fail;
|
|
205
286
|
}
|
|
206
287
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
console.log(chalk.cyan(`${'='.repeat(50)}`));
|
|
288
|
+
stats.totalElapsed = Math.floor((Date.now() - startTime) / 1000);
|
|
289
|
+
stats.timeSaved = Math.floor(stats.videosWatched * 60);
|
|
210
290
|
|
|
291
|
+
printReport(stats);
|
|
211
292
|
await browser.close();
|
|
212
293
|
}
|
|
213
294
|
|
|
214
|
-
module.exports = { run, showStatus };
|
|
295
|
+
module.exports = { run, runWithSelection, showStatus };
|
package/src/tui.js
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
const inquirer = require('inquirer');
|
|
2
|
+
const chalk = require('chalk');
|
|
3
|
+
const { loadConfig, saveConfig, manageCourses } = require('./config');
|
|
4
|
+
|
|
5
|
+
async function mainMenu() {
|
|
6
|
+
let running = true;
|
|
7
|
+
|
|
8
|
+
while (running) {
|
|
9
|
+
const config = loadConfig();
|
|
10
|
+
const { action } = await inquirer.prompt([
|
|
11
|
+
{
|
|
12
|
+
type: 'list',
|
|
13
|
+
name: 'action',
|
|
14
|
+
message: 'tronclass-auto',
|
|
15
|
+
choices: [
|
|
16
|
+
{ name: chalk.green('▶ 開始自動觀看'), value: 'run' },
|
|
17
|
+
{ name: chalk.cyan('📋 管理課程列表'), value: 'courses' },
|
|
18
|
+
{ name: chalk.yellow('⚙ 設定'), value: 'settings' },
|
|
19
|
+
new inquirer.Separator(),
|
|
20
|
+
{ name: chalk.gray('🚪 離開'), value: 'exit' }
|
|
21
|
+
],
|
|
22
|
+
pageSize: 10
|
|
23
|
+
}
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
switch (action) {
|
|
27
|
+
case 'run':
|
|
28
|
+
await runMenu(config);
|
|
29
|
+
break;
|
|
30
|
+
case 'courses':
|
|
31
|
+
await courseMenu(config);
|
|
32
|
+
break;
|
|
33
|
+
case 'settings':
|
|
34
|
+
await settingsMenu(config);
|
|
35
|
+
break;
|
|
36
|
+
case 'exit':
|
|
37
|
+
running = false;
|
|
38
|
+
break;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
console.log(chalk.gray('\n再見!\n'));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function runMenu(config) {
|
|
46
|
+
if (!config.courses.length) {
|
|
47
|
+
console.log(chalk.yellow('\n 還沒有設定課程,請先新增課程\n'));
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const courseChoices = config.courses.map((url, i) => {
|
|
52
|
+
const match = url.match(/\/course\/(\d+)\//);
|
|
53
|
+
const id = match ? match[1] : '?';
|
|
54
|
+
return { name: `[${id}] ${url}`, value: i, checked: true };
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const { selectedCourses } = await inquirer.prompt([
|
|
58
|
+
{
|
|
59
|
+
type: 'checkbox',
|
|
60
|
+
name: 'selectedCourses',
|
|
61
|
+
message: '選擇要觀看的課程:',
|
|
62
|
+
choices: courseChoices,
|
|
63
|
+
validate: (ans) => ans.length > 0 ? true : '至少選一個課程'
|
|
64
|
+
}
|
|
65
|
+
]);
|
|
66
|
+
|
|
67
|
+
const { headless } = await inquirer.prompt([
|
|
68
|
+
{
|
|
69
|
+
type: 'confirm',
|
|
70
|
+
name: 'headless',
|
|
71
|
+
message: '無頭模式(背景執行)?',
|
|
72
|
+
default: false
|
|
73
|
+
}
|
|
74
|
+
]);
|
|
75
|
+
|
|
76
|
+
console.log(chalk.cyan('\n' + '='.repeat(50)));
|
|
77
|
+
console.log(chalk.cyan(' 開始自動觀看'));
|
|
78
|
+
console.log(chalk.cyan('='.repeat(50) + '\n'));
|
|
79
|
+
|
|
80
|
+
const { runWithSelection } = require('./index');
|
|
81
|
+
await runWithSelection(selectedCourses.map(i => config.courses[i]), headless);
|
|
82
|
+
|
|
83
|
+
console.log(chalk.green('\n 按任意鍵回到選單'));
|
|
84
|
+
await inquirer.prompt([{ type: 'input', name: '_press', message: '' }]);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function courseMenu(config) {
|
|
88
|
+
let editing = true;
|
|
89
|
+
|
|
90
|
+
while (editing) {
|
|
91
|
+
const courses = config.courses || [];
|
|
92
|
+
const choices = courses.map((url, i) => {
|
|
93
|
+
const match = url.match(/\/course\/(\d+)\//);
|
|
94
|
+
const id = match ? match[1] : '?';
|
|
95
|
+
return { name: `${chalk.white(i + 1 + '.')} [${id}] ${chalk.gray(url)}`, value: i };
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const { action } = await inquirer.prompt([
|
|
99
|
+
{
|
|
100
|
+
type: 'list',
|
|
101
|
+
name: 'action',
|
|
102
|
+
message: `課程列表 (${courses.length} 個)`,
|
|
103
|
+
choices: [
|
|
104
|
+
...choices,
|
|
105
|
+
new inquirer.Separator(),
|
|
106
|
+
{ name: chalk.green('➕ 新增課程'), value: 'add' },
|
|
107
|
+
{ name: chalk.red('🗑 清空所有'), value: 'clear' },
|
|
108
|
+
new inquirer.Separator(),
|
|
109
|
+
{ name: chalk.gray('⬅ 返回'), value: 'back' }
|
|
110
|
+
],
|
|
111
|
+
pageSize: 20
|
|
112
|
+
}
|
|
113
|
+
]);
|
|
114
|
+
|
|
115
|
+
if (action === 'back') {
|
|
116
|
+
editing = false;
|
|
117
|
+
} else if (action === 'add') {
|
|
118
|
+
const { input } = await inquirer.prompt([
|
|
119
|
+
{
|
|
120
|
+
type: 'input',
|
|
121
|
+
name: 'input',
|
|
122
|
+
message: '輸入課程 ID 或 URL:',
|
|
123
|
+
validate: (v) => v.trim().length > 0 ? true : '不可為空'
|
|
124
|
+
}
|
|
125
|
+
]);
|
|
126
|
+
config.courses = config.courses || [];
|
|
127
|
+
config.courses.push(normalizeUrl(input.trim()));
|
|
128
|
+
saveConfig(config);
|
|
129
|
+
console.log(chalk.green(' 已新增!'));
|
|
130
|
+
} else if (action === 'clear') {
|
|
131
|
+
const { confirm } = await inquirer.prompt([
|
|
132
|
+
{ type: 'confirm', name: 'confirm', message: '確定要清空所有課程?', default: false }
|
|
133
|
+
]);
|
|
134
|
+
if (confirm) {
|
|
135
|
+
config.courses = [];
|
|
136
|
+
saveConfig(config);
|
|
137
|
+
console.log(chalk.green(' 已清空'));
|
|
138
|
+
}
|
|
139
|
+
} else {
|
|
140
|
+
const { subAction } = await inquirer.prompt([
|
|
141
|
+
{
|
|
142
|
+
type: 'list',
|
|
143
|
+
name: 'subAction',
|
|
144
|
+
message: courses[action],
|
|
145
|
+
choices: [
|
|
146
|
+
{ name: chalk.red('🗑 移除此課程'), value: 'remove' },
|
|
147
|
+
{ name: chalk.gray('⬅ 返回'), value: 'back' }
|
|
148
|
+
]
|
|
149
|
+
}
|
|
150
|
+
]);
|
|
151
|
+
if (subAction === 'remove') {
|
|
152
|
+
const removed = config.courses.splice(action, 1)[0];
|
|
153
|
+
saveConfig(config);
|
|
154
|
+
console.log(chalk.green(` 已移除: ${removed}`));
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function settingsMenu(config) {
|
|
161
|
+
const { setting } = await inquirer.prompt([
|
|
162
|
+
{
|
|
163
|
+
type: 'list',
|
|
164
|
+
name: 'setting',
|
|
165
|
+
message: '設定',
|
|
166
|
+
choices: [
|
|
167
|
+
{ name: `倍速: ${chalk.cyan(config.playbackRate || 2)}x`, value: 'speed' },
|
|
168
|
+
{ name: `無頭模式: ${chalk.cyan(config.headless ? '是' : '否')}`, value: 'headless' },
|
|
169
|
+
{ name: `SlowMo: ${chalk.cyan(config.slowMo || 50)}ms`, value: 'slowmo' },
|
|
170
|
+
new inquirer.Separator(),
|
|
171
|
+
{ name: chalk.gray('⬅ 返回'), value: 'back' }
|
|
172
|
+
]
|
|
173
|
+
}
|
|
174
|
+
]);
|
|
175
|
+
|
|
176
|
+
if (setting === 'back') return;
|
|
177
|
+
|
|
178
|
+
if (setting === 'speed') {
|
|
179
|
+
const { speed } = await inquirer.prompt([
|
|
180
|
+
{
|
|
181
|
+
type: 'list',
|
|
182
|
+
name: 'speed',
|
|
183
|
+
message: '選擇播放倍速:',
|
|
184
|
+
choices: [
|
|
185
|
+
{ name: '1x(正常)', value: 1 },
|
|
186
|
+
{ name: '1.5x', value: 1.5 },
|
|
187
|
+
{ name: '2x(推薦)', value: 2 },
|
|
188
|
+
{ name: '4x', value: 4 },
|
|
189
|
+
{ name: '8x', value: 8 }
|
|
190
|
+
],
|
|
191
|
+
default: config.playbackRate || 2
|
|
192
|
+
}
|
|
193
|
+
]);
|
|
194
|
+
config.playbackRate = speed;
|
|
195
|
+
saveConfig(config);
|
|
196
|
+
console.log(chalk.green(` 已設定為 ${speed}x`));
|
|
197
|
+
} else if (setting === 'headless') {
|
|
198
|
+
const { hl } = await inquirer.prompt([
|
|
199
|
+
{
|
|
200
|
+
type: 'confirm',
|
|
201
|
+
name: 'hl',
|
|
202
|
+
message: '啟用無頭模式?',
|
|
203
|
+
default: config.headless || false
|
|
204
|
+
}
|
|
205
|
+
]);
|
|
206
|
+
config.headless = hl;
|
|
207
|
+
saveConfig(config);
|
|
208
|
+
console.log(chalk.green(` 無頭模式: ${hl ? '是' : '否'}`));
|
|
209
|
+
} else if (setting === 'slowmo') {
|
|
210
|
+
const { ms } = await inquirer.prompt([
|
|
211
|
+
{
|
|
212
|
+
type: 'number',
|
|
213
|
+
name: 'ms',
|
|
214
|
+
message: 'SlowMo 毫秒數:',
|
|
215
|
+
default: config.slowMo || 50,
|
|
216
|
+
validate: (v) => v >= 0 ? true : '不可小於 0'
|
|
217
|
+
}
|
|
218
|
+
]);
|
|
219
|
+
config.slowMo = ms;
|
|
220
|
+
saveConfig(config);
|
|
221
|
+
console.log(chalk.green(` SlowMo: ${ms}ms`));
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function normalizeUrl(input) {
|
|
226
|
+
if (input.match(/^\d+$/)) {
|
|
227
|
+
return `https://eclass.yuntech.edu.tw/course/${input}/content#/`;
|
|
228
|
+
}
|
|
229
|
+
if (!input.startsWith('http')) {
|
|
230
|
+
return `https://eclass.yuntech.edu.tw/course/${input}/content#/`;
|
|
231
|
+
}
|
|
232
|
+
return input;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
module.exports = { mainMenu };
|
package/src/video.js
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
const chalk = require('chalk');
|
|
2
2
|
|
|
3
|
-
function
|
|
4
|
-
|
|
3
|
+
function formatTime(seconds) {
|
|
4
|
+
const h = Math.floor(seconds / 3600);
|
|
5
|
+
const m = Math.floor((seconds % 3600) / 60);
|
|
6
|
+
const s = Math.floor(seconds % 60);
|
|
7
|
+
if (h > 0) return `${h}h ${m}m ${s}s`;
|
|
8
|
+
if (m > 0) return `${m}m ${s}s`;
|
|
9
|
+
return `${s}s`;
|
|
5
10
|
}
|
|
6
11
|
|
|
7
|
-
async function watchVideo(page) {
|
|
12
|
+
async function watchVideo(page, stats) {
|
|
8
13
|
const hasVideo = await page.evaluate(() => document.querySelector('video') !== null);
|
|
9
14
|
if (!hasVideo) {
|
|
10
15
|
log(chalk.yellow(' [VIDEO] No video element'));
|
|
@@ -50,11 +55,17 @@ async function watchVideo(page) {
|
|
|
50
55
|
if (!duration || duration <= 0) {
|
|
51
56
|
log(chalk.yellow(' [VIDEO] Cannot get duration, waiting 30s...'));
|
|
52
57
|
await page.waitForTimeout(30000);
|
|
58
|
+
if (stats) stats.videosWatched++;
|
|
53
59
|
return true;
|
|
54
60
|
}
|
|
55
61
|
|
|
56
62
|
const waitTime = (duration / 2) + 15;
|
|
57
|
-
log(chalk.blue(` [VIDEO] ${
|
|
63
|
+
log(chalk.blue(` [VIDEO] ${formatTime(duration)} video, waiting ~${formatTime(waitTime)} (2x)`));
|
|
64
|
+
|
|
65
|
+
if (stats) {
|
|
66
|
+
stats.currentVideoDuration = duration / 2;
|
|
67
|
+
stats.currentVideoElapsed = 0;
|
|
68
|
+
}
|
|
58
69
|
|
|
59
70
|
let elapsed = 0;
|
|
60
71
|
let lastPct = -1;
|
|
@@ -64,6 +75,8 @@ async function watchVideo(page) {
|
|
|
64
75
|
await page.waitForTimeout(5000);
|
|
65
76
|
elapsed += 5;
|
|
66
77
|
|
|
78
|
+
if (stats) stats.currentVideoElapsed = elapsed;
|
|
79
|
+
|
|
67
80
|
const state = await page.evaluate(() => {
|
|
68
81
|
const v = document.querySelector('video');
|
|
69
82
|
if (!v || !v.duration) return { p: 100, e: true, paused: false, stalled: false };
|
|
@@ -76,8 +89,17 @@ async function watchVideo(page) {
|
|
|
76
89
|
});
|
|
77
90
|
|
|
78
91
|
if (state.p > lastPct) {
|
|
79
|
-
|
|
80
|
-
|
|
92
|
+
const pct = state.p;
|
|
93
|
+
const remaining = Math.max(0, waitTime - elapsed);
|
|
94
|
+
let etaStr = '';
|
|
95
|
+
if (stats && stats.remainingVideos > 0) {
|
|
96
|
+
const totalRemaining = remaining + stats.estimatedTimeForRemaining;
|
|
97
|
+
etaStr = chalk.gray(` | ETA: ${formatTime(totalRemaining)}`);
|
|
98
|
+
} else {
|
|
99
|
+
etaStr = chalk.gray(` | ~${formatTime(remaining)} left`);
|
|
100
|
+
}
|
|
101
|
+
log(chalk.blue(` [VIDEO] ${pct}% (${formatTime(elapsed)})${etaStr}`));
|
|
102
|
+
lastPct = pct;
|
|
81
103
|
}
|
|
82
104
|
|
|
83
105
|
if (state.e) {
|
|
@@ -87,7 +109,7 @@ async function watchVideo(page) {
|
|
|
87
109
|
|
|
88
110
|
if (state.stalled) {
|
|
89
111
|
if (elapsed % 15 === 0) {
|
|
90
|
-
log(chalk.gray(` [VIDEO] Buffering... (${
|
|
112
|
+
log(chalk.gray(` [VIDEO] Buffering... (${formatTime(elapsed)})`));
|
|
91
113
|
}
|
|
92
114
|
continue;
|
|
93
115
|
}
|
|
@@ -105,7 +127,35 @@ async function watchVideo(page) {
|
|
|
105
127
|
}
|
|
106
128
|
|
|
107
129
|
await page.waitForTimeout(3000);
|
|
130
|
+
if (stats) stats.videosWatched++;
|
|
108
131
|
return true;
|
|
109
132
|
}
|
|
110
133
|
|
|
111
|
-
|
|
134
|
+
function log(msg) {
|
|
135
|
+
console.log(msg);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function printReport(stats) {
|
|
139
|
+
console.log('');
|
|
140
|
+
console.log(chalk.cyan('┌' + '─'.repeat(48) + '┐'));
|
|
141
|
+
console.log(chalk.cyan('│') + chalk.bold.white(' 📊 報表' + ' '.repeat(39)) + chalk.cyan('│'));
|
|
142
|
+
console.log(chalk.cyan('├' + '─'.repeat(48) + '┤'));
|
|
143
|
+
console.log(chalk.cyan('│') + ` 已觀看: ${chalk.green(stats.videosWatched + ' 部')}`.padEnd(50) + chalk.cyan('│'));
|
|
144
|
+
console.log(chalk.cyan('│') + ` 已跳過: ${chalk.yellow(stats.videosSkipped + ' 部')}`.padEnd(50) + chalk.cyan('│'));
|
|
145
|
+
console.log(chalk.cyan('│') + ` 失敗: ${chalk.red(stats.videosFailed + ' 部')}`.padEnd(50) + chalk.cyan('│'));
|
|
146
|
+
console.log(chalk.cyan('├' + '─'.repeat(48) + '┤'));
|
|
147
|
+
console.log(chalk.cyan('│') + ` 總花費: ${chalk.white(formatTime(stats.totalElapsed))}`.padEnd(50) + chalk.cyan('│'));
|
|
148
|
+
console.log(chalk.cyan('│') + ` 預估節省: ${chalk.green(formatTime(stats.timeSaved))}`.padEnd(50) + chalk.cyan('│'));
|
|
149
|
+
console.log(chalk.cyan('├' + '─'.repeat(48) + '┤'));
|
|
150
|
+
if (stats.courseResults.length > 0) {
|
|
151
|
+
stats.courseResults.forEach(cr => {
|
|
152
|
+
console.log(chalk.cyan('│') + ` 課程 ${cr.id}: ${chalk.green(cr.done + '✓')} ${chalk.yellow(cr.skip + '⊘')} ${chalk.red(cr.fail + '✗')}`.padEnd(50) + chalk.cyan('│'));
|
|
153
|
+
});
|
|
154
|
+
console.log(chalk.cyan('├' + '─'.repeat(48) + '┤'));
|
|
155
|
+
}
|
|
156
|
+
console.log(chalk.cyan('│') + ` 完成時間: ${chalk.white(new Date().toLocaleString())}`.padEnd(52) + chalk.cyan('│'));
|
|
157
|
+
console.log(chalk.cyan('└' + '─'.repeat(48) + '┘'));
|
|
158
|
+
console.log('');
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
module.exports = { watchVideo, formatTime, printReport };
|