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 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
- program.parse();
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.2",
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": ["eclass", "tronclass", "yuntech", "automation", "video"],
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
- "open": "^8.4.2"
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 (!url.includes('login') && !url.includes('auth') && !url.includes('cas')) {
108
- if (!saved) {
109
- saved = true;
110
- const cookies = await context.cookies();
111
- const eclassCookies = cookies.filter(c =>
112
- c.domain.includes('yuntech.edu.tw') || c.domain.includes('eclass')
113
- );
114
-
115
- if (eclassCookies.length > 0) {
116
- saveCookies(eclassCookies);
117
- console.log(chalk.green(`\n[AUTH] 登入成功!自動取得 ${eclassCookies.length} 個 Cookie`));
118
- console.log(chalk.green('[AUTH] 現在可以執行 tronclass run 開始自動化\n'));
119
- } else {
120
- console.log(chalk.yellow('\n[AUTH] 找不到 eclass Cookie,請確認已成功登入'));
121
- }
122
-
123
- clearInterval(checkInterval);
124
- await browser.close();
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, autoLogin } = re('./auth');
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
- for (const act of remaining) {
52
- console.log(chalk.white(`\n -> ${act.title}`));
53
- const url = `${BASE_URL}/course/${courseId}/learning-activity/full-screen#/${act.id}`;
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, skipping'));
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
- const success = await watchVideo(page);
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 to progress'));
81
+ console.log(chalk.green(' [OK] Saved'));
72
82
  } else {
73
- skip++;
83
+ fail++;
74
84
  }
75
85
  } catch (e) {
76
86
  console.log(chalk.red(` [ERROR] ${e.message}`));
77
- skip++;
87
+ fail++;
78
88
  }
79
89
 
80
90
  await page.waitForTimeout(2000);
81
91
  }
82
92
 
83
- console.log(chalk.cyan(`\n Result: ${done} watched, ${skip} skipped`));
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 或 tronclass import-cookies'));
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 total = uncompleted.length;
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 = total - videos - exams;
126
- console.log(chalk.white(`\n Course ${courseId}: ${total} uncompleted (${videos} videos, ${exams} exams, ${other} other)`));
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.red(`\n Course ${courseId}: ERROR - ${e.message}`));
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 { chromium } = re('playwright');
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 過期,請重新執行 eclass login'));
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(' 請編輯 ~/.eclass-auto/config.json 加入 courses'));
222
+ console.log(chalk.yellow(' 請執行 tronclass course --add <課程ID>'));
186
223
  await browser.close();
187
224
  return;
188
225
  }
189
226
 
190
- let totalDone = 0;
191
- let totalSkip = 0;
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] : '127331';
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
- totalDone += result.done;
204
- totalSkip += result.skip;
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
- console.log(chalk.cyan(`\n${'='.repeat(50)}`));
208
- console.log(chalk.green(`ALL DONE: ${totalDone} watched, ${totalSkip} skipped`));
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 log(msg) {
4
- console.log(msg);
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] ${Math.floor(duration)}s video, waiting ~${Math.floor(waitTime)}s (2x)`));
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
- log(chalk.blue(` [VIDEO] ${state.p}% (${Math.floor(elapsed)}s)`));
80
- lastPct = state.p;
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... (${Math.floor(elapsed)}s)`));
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
- module.exports = { watchVideo };
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 };