tronclass-auto 1.0.2 → 1.2.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/auth.js +40 -18
- package/src/index.js +127 -46
- package/src/tui.js +247 -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.2.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/auth.js
CHANGED
|
@@ -97,32 +97,54 @@ async function autoLogin(baseUrl) {
|
|
|
97
97
|
console.log(chalk.cyan('等待您完成登入...'));
|
|
98
98
|
console.log(chalk.cyan('(登入後頁面會自動跳轉,Cookie 會自動儲存)'));
|
|
99
99
|
|
|
100
|
+
let sawLogin = false;
|
|
100
101
|
let saved = false;
|
|
101
102
|
let checkCount = 0;
|
|
102
103
|
|
|
103
104
|
const checkInterval = setInterval(async () => {
|
|
104
105
|
checkCount++;
|
|
105
106
|
const url = page.url();
|
|
107
|
+
const isLoginPage = url.includes('login') || url.includes('auth') || url.includes('cas') || url.includes('signin');
|
|
106
108
|
|
|
107
|
-
if (
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
109
|
+
if (isLoginPage) {
|
|
110
|
+
sawLogin = true;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (sawLogin && !isLoginPage && !saved) {
|
|
114
|
+
saved = true;
|
|
115
|
+
console.log(chalk.cyan('偵測到頁面跳轉,驗證登入狀態...'));
|
|
116
|
+
await page.waitForTimeout(3000);
|
|
117
|
+
|
|
118
|
+
const isLoggedIn = await page.evaluate(() => {
|
|
119
|
+
return document.querySelector('[class*="profile"]') !== null ||
|
|
120
|
+
document.querySelector('[class*="avatar"]') !== null ||
|
|
121
|
+
document.querySelector('[class*="user-name"]') !== null ||
|
|
122
|
+
document.querySelector('[ng-click*="showUserOperationList"]') !== null ||
|
|
123
|
+
document.querySelector('a[href*="logout"]') !== null ||
|
|
124
|
+
document.querySelector('a[href*="settings"]') !== null;
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
if (!isLoggedIn) {
|
|
128
|
+
console.log(chalk.yellow(' 頁面已跳轉但未偵測到登入狀態,可能未完成登入'));
|
|
129
|
+
saved = false;
|
|
130
|
+
return;
|
|
125
131
|
}
|
|
132
|
+
|
|
133
|
+
const cookies = await context.cookies();
|
|
134
|
+
const eclassCookies = cookies.filter(c =>
|
|
135
|
+
c.domain.includes('yuntech.edu.tw') || c.domain.includes('eclass')
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
if (eclassCookies.length > 0) {
|
|
139
|
+
saveCookies(eclassCookies);
|
|
140
|
+
console.log(chalk.green(`\n[AUTH] 登入成功!自動取得 ${eclassCookies.length} 個 Cookie`));
|
|
141
|
+
console.log(chalk.green('[AUTH] 現在可以執行 tronclass run 開始自動化\n'));
|
|
142
|
+
} else {
|
|
143
|
+
console.log(chalk.yellow('\n[AUTH] 找不到 eclass Cookie,請確認已成功登入'));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
clearInterval(checkInterval);
|
|
147
|
+
await browser.close();
|
|
126
148
|
}
|
|
127
149
|
|
|
128
150
|
if (checkCount > 300) {
|
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,247 @@
|
|
|
1
|
+
const inquirer = require('inquirer');
|
|
2
|
+
const chalk = require('chalk');
|
|
3
|
+
const { loadConfig, saveConfig, BASE_URL } = require('./config');
|
|
4
|
+
const { loadCookies } = require('./auth');
|
|
5
|
+
|
|
6
|
+
function box(lines, width = 52) {
|
|
7
|
+
const top = '╔' + '═'.repeat(width - 2) + '╗';
|
|
8
|
+
const bot = '╚' + '═'.repeat(width - 2) + '╝';
|
|
9
|
+
const pad = (s) => '║ ' + s.padEnd(width - 4) + ' ║';
|
|
10
|
+
console.log(chalk.cyan(top));
|
|
11
|
+
lines.forEach(l => console.log(chalk.cyan(pad(l))));
|
|
12
|
+
console.log(chalk.cyan(bot));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async function mainMenu() {
|
|
16
|
+
const cookies = loadCookies();
|
|
17
|
+
if (!cookies.length) {
|
|
18
|
+
console.log(chalk.red('\n 沒有 Cookie,請先執行 tronclass login\n'));
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
let running = true;
|
|
23
|
+
while (running) {
|
|
24
|
+
console.clear();
|
|
25
|
+
const config = loadConfig();
|
|
26
|
+
|
|
27
|
+
box([
|
|
28
|
+
chalk.bold.white('tronclass-auto v1.1.0'),
|
|
29
|
+
chalk.gray('自動觀看 eclass/TronClass 影片'),
|
|
30
|
+
'',
|
|
31
|
+
`課程: ${chalk.cyan(config.courses.length + ' 個')} | Cookie: ${chalk.green('✓')} | 倍速: ${chalk.yellow(config.playbackRate || 2)}x`,
|
|
32
|
+
]);
|
|
33
|
+
console.log('');
|
|
34
|
+
|
|
35
|
+
const { action } = await inquirer.prompt([{
|
|
36
|
+
type: 'list',
|
|
37
|
+
name: 'action',
|
|
38
|
+
message: '選擇操作:',
|
|
39
|
+
choices: [
|
|
40
|
+
{ name: chalk.green('▶ 開始自動觀看'), value: 'run' },
|
|
41
|
+
{ name: chalk.cyan('📋 管理課程列表'), value: 'courses' },
|
|
42
|
+
{ name: chalk.yellow('📊 查看進度統計'), value: 'status' },
|
|
43
|
+
{ name: chalk.magenta('⚙ 設定'), value: 'settings' },
|
|
44
|
+
new inquirer.Separator(),
|
|
45
|
+
{ name: chalk.gray('🚪 離開'), value: 'exit' }
|
|
46
|
+
],
|
|
47
|
+
pageSize: 10
|
|
48
|
+
}]);
|
|
49
|
+
|
|
50
|
+
switch (action) {
|
|
51
|
+
case 'run': await runMenu(config); break;
|
|
52
|
+
case 'courses': await courseMenu(); break;
|
|
53
|
+
case 'status': {
|
|
54
|
+
const { showStatus } = require('./index');
|
|
55
|
+
await showStatus();
|
|
56
|
+
await pressAnyKey();
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
case 'settings': await settingsMenu(); break;
|
|
60
|
+
case 'exit': running = false; break;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
console.log(chalk.gray('\n再見!\n'));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function runMenu(config) {
|
|
67
|
+
if (!config.courses.length) {
|
|
68
|
+
console.log(chalk.yellow('\n 還沒有設定課程,請先新增\n'));
|
|
69
|
+
await pressAnyKey();
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const courseChoices = config.courses.map((url, i) => {
|
|
74
|
+
const match = url.match(/\/course\/(\d+)\//);
|
|
75
|
+
const id = match ? match[1] : '?';
|
|
76
|
+
return { name: `[${id}] ${url}`, value: i, checked: true };
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const { selectedCourses } = await inquirer.prompt([{
|
|
80
|
+
type: 'checkbox',
|
|
81
|
+
name: 'selectedCourses',
|
|
82
|
+
message: '選擇要觀看的課程:',
|
|
83
|
+
choices: courseChoices,
|
|
84
|
+
validate: (ans) => ans.length > 0 ? true : '至少選一個課程'
|
|
85
|
+
}]);
|
|
86
|
+
|
|
87
|
+
const { headless } = await inquirer.prompt([{
|
|
88
|
+
type: 'confirm',
|
|
89
|
+
name: 'headless',
|
|
90
|
+
message: '無頭模式(背景執行)?',
|
|
91
|
+
default: false
|
|
92
|
+
}]);
|
|
93
|
+
|
|
94
|
+
console.log('');
|
|
95
|
+
box([
|
|
96
|
+
chalk.green('開始自動觀看'),
|
|
97
|
+
`課程: ${chalk.cyan(selectedCourses.length)} 個`,
|
|
98
|
+
`模式: ${headless ? chalk.gray('無頭') : chalk.white('有頭')}`,
|
|
99
|
+
`倍速: ${chalk.yellow(config.playbackRate || 2)}x`,
|
|
100
|
+
]);
|
|
101
|
+
console.log('');
|
|
102
|
+
|
|
103
|
+
const { runWithSelection } = require('./index');
|
|
104
|
+
await runWithSelection(selectedCourses.map(i => config.courses[i]), headless);
|
|
105
|
+
await pressAnyKey();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function courseMenu() {
|
|
109
|
+
let editing = true;
|
|
110
|
+
while (editing) {
|
|
111
|
+
const config = loadConfig();
|
|
112
|
+
const courses = config.courses || [];
|
|
113
|
+
|
|
114
|
+
console.clear();
|
|
115
|
+
box([
|
|
116
|
+
chalk.bold.white('📋 課程列表'),
|
|
117
|
+
...(courses.length === 0
|
|
118
|
+
? [chalk.gray(' 還沒有課程')]
|
|
119
|
+
: courses.map((url, i) => {
|
|
120
|
+
const match = url.match(/\/course\/(\d+)\//);
|
|
121
|
+
const id = match ? match[1] : '?';
|
|
122
|
+
return ` ${chalk.white(i + 1 + '.')} [${chalk.cyan(id)}] ${chalk.gray(url.substring(0, 45))}`;
|
|
123
|
+
})),
|
|
124
|
+
'',
|
|
125
|
+
chalk.gray(' 新增: tronclass course --add <ID或URL>'),
|
|
126
|
+
chalk.gray(' 移除: tronclass course --remove <編號>'),
|
|
127
|
+
]);
|
|
128
|
+
console.log('');
|
|
129
|
+
|
|
130
|
+
const { action } = await inquirer.prompt([{
|
|
131
|
+
type: 'list',
|
|
132
|
+
name: 'action',
|
|
133
|
+
message: `課程管理 (${courses.length} 個)`,
|
|
134
|
+
choices: [
|
|
135
|
+
{ name: chalk.green('➕ 新增課程(互動式)'), value: 'add' },
|
|
136
|
+
...(courses.length > 0 ? [
|
|
137
|
+
...courses.map((url, i) => {
|
|
138
|
+
const match = url.match(/\/course\/(\d+)\//);
|
|
139
|
+
const id = match ? match[1] : '?';
|
|
140
|
+
return { name: `${chalk.red('✕')} [${id}] 移除`, value: `remove_${i}` };
|
|
141
|
+
}),
|
|
142
|
+
new inquirer.Separator(),
|
|
143
|
+
{ name: chalk.red('🗑 清空所有'), value: 'clear' }
|
|
144
|
+
] : []),
|
|
145
|
+
new inquirer.Separator(),
|
|
146
|
+
{ name: chalk.gray('⬅ 返回'), value: 'back' }
|
|
147
|
+
],
|
|
148
|
+
pageSize: 20
|
|
149
|
+
}]);
|
|
150
|
+
|
|
151
|
+
if (action === 'back') {
|
|
152
|
+
editing = false;
|
|
153
|
+
} else if (action === 'add') {
|
|
154
|
+
const { input } = await inquirer.prompt([{
|
|
155
|
+
type: 'input',
|
|
156
|
+
name: 'input',
|
|
157
|
+
message: '輸入課程 ID(如 127331)或完整 URL:',
|
|
158
|
+
validate: (v) => v.trim().length > 0 ? true : '不可為空'
|
|
159
|
+
}]);
|
|
160
|
+
const config2 = loadConfig();
|
|
161
|
+
config2.courses = config2.courses || [];
|
|
162
|
+
config2.courses.push(normalizeUrl(input.trim()));
|
|
163
|
+
saveConfig(config2);
|
|
164
|
+
console.log(chalk.green(' 已新增!'));
|
|
165
|
+
} else if (action === 'clear') {
|
|
166
|
+
const { confirm } = await inquirer.prompt([{
|
|
167
|
+
type: 'confirm', name: 'confirm', message: '確定要清空所有課程?', default: false
|
|
168
|
+
}]);
|
|
169
|
+
if (confirm) {
|
|
170
|
+
const config2 = loadConfig();
|
|
171
|
+
config2.courses = [];
|
|
172
|
+
saveConfig(config2);
|
|
173
|
+
console.log(chalk.green(' 已清空'));
|
|
174
|
+
}
|
|
175
|
+
} else if (action.startsWith('remove_')) {
|
|
176
|
+
const idx = parseInt(action.split('_')[1]);
|
|
177
|
+
const config2 = loadConfig();
|
|
178
|
+
const removed = config2.courses.splice(idx, 1)[0];
|
|
179
|
+
saveConfig(config2);
|
|
180
|
+
console.log(chalk.green(` 已移除: ${removed}`));
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function settingsMenu() {
|
|
186
|
+
const config = loadConfig();
|
|
187
|
+
|
|
188
|
+
const { setting } = await inquirer.prompt([{
|
|
189
|
+
type: 'list',
|
|
190
|
+
name: 'setting',
|
|
191
|
+
message: '設定',
|
|
192
|
+
choices: [
|
|
193
|
+
{ name: `倍速: ${chalk.cyan(config.playbackRate || 2)}x`, value: 'speed' },
|
|
194
|
+
{ name: `無頭模式: ${chalk.cyan(config.headless ? '是' : '否')}`, value: 'headless' },
|
|
195
|
+
{ name: `SlowMo: ${chalk.cyan(config.slowMo || 50)}ms`, value: 'slowmo' },
|
|
196
|
+
new inquirer.Separator(),
|
|
197
|
+
{ name: chalk.gray('⬅ 返回'), value: 'back' }
|
|
198
|
+
]
|
|
199
|
+
}]);
|
|
200
|
+
|
|
201
|
+
if (setting === 'back') return;
|
|
202
|
+
|
|
203
|
+
if (setting === 'speed') {
|
|
204
|
+
const { speed } = await inquirer.prompt([{
|
|
205
|
+
type: 'list', name: 'speed', message: '選擇播放倍速:',
|
|
206
|
+
choices: [
|
|
207
|
+
{ name: '1x(正常)', value: 1 },
|
|
208
|
+
{ name: '1.5x', value: 1.5 },
|
|
209
|
+
{ name: '2x(推薦)', value: 2 },
|
|
210
|
+
{ name: '4x', value: 4 },
|
|
211
|
+
{ name: '8x', value: 8 }
|
|
212
|
+
],
|
|
213
|
+
default: config.playbackRate || 2
|
|
214
|
+
}]);
|
|
215
|
+
config.playbackRate = speed;
|
|
216
|
+
saveConfig(config);
|
|
217
|
+
console.log(chalk.green(` 已設定為 ${speed}x`));
|
|
218
|
+
} else if (setting === 'headless') {
|
|
219
|
+
const { hl } = await inquirer.prompt([{
|
|
220
|
+
type: 'confirm', name: 'hl', message: '啟用無頭模式?', default: config.headless || false
|
|
221
|
+
}]);
|
|
222
|
+
config.headless = hl;
|
|
223
|
+
saveConfig(config);
|
|
224
|
+
} else if (setting === 'slowmo') {
|
|
225
|
+
const { ms } = await inquirer.prompt([{
|
|
226
|
+
type: 'number', name: 'ms', message: 'SlowMo 毫秒數:', default: config.slowMo || 50
|
|
227
|
+
}]);
|
|
228
|
+
config.slowMo = ms;
|
|
229
|
+
saveConfig(config);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function normalizeUrl(input) {
|
|
234
|
+
if (/^\d+$/.test(input)) {
|
|
235
|
+
return `${BASE_URL}/course/${input}/content#/`;
|
|
236
|
+
}
|
|
237
|
+
if (!input.startsWith('http')) {
|
|
238
|
+
return `${BASE_URL}/course/${input}/content#/`;
|
|
239
|
+
}
|
|
240
|
+
return input;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async function pressAnyKey() {
|
|
244
|
+
await inquirer.prompt([{ type: 'input', name: '_', message: chalk.gray('按 Enter 返回...') }]);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
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 };
|