tronclass-auto 1.6.0 → 1.7.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tronclass-auto",
3
- "version": "1.6.0",
3
+ "version": "1.7.1",
4
4
  "description": "自動觀看 eclass/TronClass 影片、填寫表單的 CLI 工具",
5
5
  "main": "src/index.js",
6
6
  "bin": {
package/src/index.js CHANGED
@@ -28,6 +28,8 @@ async function getUncompleted(page, courseId) {
28
28
  try { completeness = scope.getActivityCompleteness(a); } catch (e) {}
29
29
  if (completeness === 'full') return;
30
30
 
31
+ if (a.completion_criterion_value === '' || a.completion_criterion_value === undefined || a.completion_criterion_value === null) return;
32
+
31
33
  let isUpcoming = false;
32
34
  let isExpired = false;
33
35
  try { isUpcoming = scope.activityUpcoming(a); } catch (e) {}
@@ -211,6 +213,50 @@ async function processCourse(page, courseId, stats) {
211
213
  return { done, skip, fail };
212
214
  }
213
215
 
216
+ async function getAllVideos(page, courseId) {
217
+ try {
218
+ await page.goto(`${BASE_URL}/course/${courseId}/content#/`, {
219
+ waitUntil: 'domcontentloaded', timeout: 20000
220
+ });
221
+ } catch (e) {}
222
+ await page.waitForTimeout(8000);
223
+
224
+ return page.evaluate(() => {
225
+ const el = document.querySelector('.learning-activities');
226
+ if (!el) return [];
227
+ const scope = angular.element(el).scope();
228
+ if (!scope) return [];
229
+ const activities = document.querySelectorAll('.learning-activity.sortable');
230
+ let result = [];
231
+ activities.forEach((actEl) => {
232
+ const actScope = angular.element(actEl).scope();
233
+ if (!actScope || !actScope.activity) return;
234
+ const a = actScope.activity;
235
+ let completeness = '';
236
+ try { completeness = scope.getActivityCompleteness(a); } catch(e) {}
237
+ let isUpcoming = false;
238
+ try { isUpcoming = scope.activityUpcoming(a); } catch(e) {}
239
+ let startTime = '';
240
+ try {
241
+ const txt = actEl.textContent || '';
242
+ const tm = txt.match(/(\d{4}-\d{2}-\d{2})\s+\d{2}:\d{2}/);
243
+ if (tm) startTime = tm[1];
244
+ } catch(e) {}
245
+ const now = new Date();
246
+ const startDate = startTime ? new Date(startTime) : null;
247
+ const isFuture = startDate && startDate > now;
248
+ result.push({
249
+ id: a.id,
250
+ type: a.type || '',
251
+ completeness: completeness,
252
+ isUpcoming: isUpcoming,
253
+ isFuture: isFuture
254
+ });
255
+ });
256
+ return result;
257
+ });
258
+ }
259
+
214
260
  async function showStatus() {
215
261
  const config = loadConfig();
216
262
  const cookies = loadCookies();
@@ -227,16 +273,17 @@ async function showStatus() {
227
273
  const browser = await chromium.launch(launchOpts);
228
274
  const context = await browser.newContext();
229
275
  await context.addCookies(cookies);
230
- const page = await context.newPage();
231
276
 
232
- await page.goto(BASE_URL, { waitUntil: 'domcontentloaded', timeout: 15000 });
233
- await page.waitForTimeout(2000);
277
+ const checkPage = await context.newPage();
278
+ await checkPage.goto(BASE_URL, { waitUntil: 'domcontentloaded', timeout: 15000 });
279
+ await checkPage.waitForTimeout(2000);
234
280
 
235
- if (page.url().includes('login')) {
281
+ if (checkPage.url().includes('login')) {
236
282
  console.log(chalk.red('[ERROR] Cookie 過期'));
237
283
  await browser.close();
238
284
  return;
239
285
  }
286
+ await checkPage.close();
240
287
 
241
288
  const courses = config.courses || [];
242
289
  console.log(chalk.cyan('\n┌' + '─'.repeat(48) + '┐'));
@@ -247,27 +294,41 @@ async function showStatus() {
247
294
  let totalRemaining = 0;
248
295
  let totalExams = 0;
249
296
 
250
- for (const courseUrl of courses) {
297
+ const courseResults = await Promise.all(courses.map(async (courseUrl) => {
251
298
  const match = courseUrl.match(/\/course\/(\d+)\//);
252
299
  const courseId = match ? match[1] : '?';
253
300
  try {
254
- const uncompleted = await getUncompleted(page, courseId);
255
- const videos = uncompleted.filter(u => u.type === 'online_video');
256
- const remaining = videos.filter(v => !isDone(courseId, v.id));
257
- const exams = uncompleted.filter(u => u.type === 'exam').length;
301
+ const pg = await context.newPage();
302
+ const allVideos = await getAllVideos(pg, courseId);
303
+ await pg.close();
304
+ return { courseId, allVideos };
305
+ } catch (e) {
306
+ return { courseId, allVideos: [] };
307
+ }
308
+ }));
309
+
310
+ for (const { courseId, allVideos } of courseResults) {
311
+ try {
312
+ const videos = allVideos.filter(u => u.type === 'online_video');
313
+ const completed = videos.filter(v => v.completeness === 'full').length;
314
+ const future = videos.filter(v => v.isFuture && v.completeness !== 'full').length;
315
+ const remaining = videos.filter(v => v.completeness !== 'full' && !v.isFuture).length;
316
+ const exams = allVideos.filter(u => u.type === 'exam' && u.completeness !== 'full').length;
258
317
  totalVideos += videos.length;
259
- totalRemaining += remaining.length;
318
+ totalRemaining += remaining;
260
319
  totalExams += exams;
261
320
 
262
- const pct = videos.length > 0 ? Math.floor(((videos.length - remaining.length) / videos.length) * 100) : 100;
321
+ const pct = videos.length > 0 ? Math.floor((completed / videos.length) * 100) : 100;
263
322
  const filled = Math.floor(50 * pct / 100);
264
323
  const empty = 50 - filled;
265
324
  const bar = pct === 100
266
325
  ? chalk.green('█'.repeat(50))
267
326
  : chalk.green('█'.repeat(filled)) + chalk.gray('░'.repeat(empty));
268
327
 
269
- const doneCount = videos.length - remaining.length;
270
- console.log(chalk.cyan('│') + ` [${courseId}] ${doneCount}/${videos.length} videos ${exams} exams`.padEnd(50) + chalk.cyan('│'));
328
+ const doneCount = completed;
329
+ const pending = remaining;
330
+ const info = future > 0 ? ` (${future} 未開放)` : '';
331
+ console.log(chalk.cyan('│') + ` [${courseId}] ${doneCount}/${videos.length} videos ${exams} exams${info}`.padEnd(50) + chalk.cyan('│'));
271
332
  console.log(chalk.cyan('│') + ` ${bar} ${pct}%`.padEnd(52) + chalk.cyan('│'));
272
333
  } catch (e) {
273
334
  console.log(chalk.cyan('│') + ` [${courseId}] ERROR`.padEnd(50) + chalk.cyan('│'));
package/src/tui.js CHANGED
@@ -1,16 +1,141 @@
1
- const inquirer = require('inquirer');
1
+ const readline = require('readline');
2
2
  const chalk = require('chalk');
3
3
  const { loadConfig, saveConfig, BASE_URL } = require('./config');
4
4
  const { loadCookies } = require('./auth');
5
5
  const pkg = require('../package.json');
6
6
 
7
7
  function box(lines, width = 52) {
8
- const top = '╔' + '═'.repeat(width - 2) + '╗';
9
- const bot = '╚' + '═'.repeat(width - 2) + '╝';
10
- const pad = (s) => '║ ' + s.padEnd(width - 4) + ' ║';
11
- console.log(chalk.cyan(top));
12
- lines.forEach(l => console.log(chalk.cyan(pad(l))));
13
- console.log(chalk.cyan(bot));
8
+ console.log(chalk.cyan('╔' + '═'.repeat(width - 2) + '╗'));
9
+ lines.forEach(l => console.log(chalk.cyan('║ ') + l.padEnd(width - 4) + chalk.cyan(' ║')));
10
+ console.log(chalk.cyan('╚' + '═'.repeat(width - 2) + '╝'));
11
+ }
12
+
13
+ function promptList(message, choices, header) {
14
+ return new Promise((resolve) => {
15
+ let selected = 0;
16
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
17
+
18
+ function render() {
19
+ const lines = [];
20
+ lines.push('');
21
+ choices.forEach((c, i) => {
22
+ const prefix = i === selected ? chalk.green('>') : ' ';
23
+ if (typeof c === 'string' && c === '__sep__') {
24
+ lines.push(chalk.gray(' ─────────────'));
25
+ } else {
26
+ const label = typeof c === 'string' ? c : c.name;
27
+ lines.push(` ${prefix} ${label}`);
28
+ }
29
+ });
30
+ lines.push('');
31
+ lines.push(chalk.gray(' ↑↓ 移動 Enter 確認 ESC 返回'));
32
+
33
+ const headerLines = header || [];
34
+ const totalLines = headerLines.length + lines.length;
35
+
36
+ process.stdout.write('\x1B[2J\x1B[H');
37
+ headerLines.forEach(l => process.stdout.write(l + '\n'));
38
+ lines.forEach(l => process.stdout.write(l + '\n'));
39
+ }
40
+
41
+ render();
42
+
43
+ process.stdin.setRawMode(true);
44
+ process.stdin.resume();
45
+
46
+ function onData(data) {
47
+ const key = data.toString();
48
+
49
+ if (key === '\u001b[A') {
50
+ selected = Math.max(0, selected - 1);
51
+ while (selected < choices.length && (typeof choices[selected] === 'string' || choices[selected].value === '__sep__')) {
52
+ selected = Math.max(0, selected - 1);
53
+ }
54
+ render();
55
+ } else if (key === '\u001b[B') {
56
+ selected = Math.min(choices.length - 1, selected + 1);
57
+ while (selected < choices.length && (typeof choices[selected] === 'string' || choices[selected].value === '__sep__')) {
58
+ selected = Math.min(choices.length - 1, selected + 1);
59
+ }
60
+ render();
61
+ } else if (key === '\r' || key === '\n') {
62
+ cleanup();
63
+ const choice = choices[selected];
64
+ resolve(typeof choice === 'string' ? choice : choice.value);
65
+ } else if (key === '\u001b') {
66
+ cleanup();
67
+ resolve('__esc__');
68
+ }
69
+ }
70
+
71
+ function cleanup() {
72
+ process.stdin.removeListener('data', onData);
73
+ process.stdin.setRawMode(false);
74
+ process.stdin.pause();
75
+ rl.close();
76
+ }
77
+
78
+ process.stdin.on('data', onData);
79
+ });
80
+ }
81
+
82
+ function promptConfirm(message, defaultVal = true) {
83
+ return new Promise((resolve) => {
84
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
85
+ process.stdout.write(` ${message} (${defaultVal ? 'Y/n' : 'y/N'}) `);
86
+
87
+ process.stdin.setRawMode(true);
88
+ process.stdin.resume();
89
+
90
+ function onData(data) {
91
+ const key = data.toString();
92
+ if (key === '\r' || key === '\n') {
93
+ cleanup();
94
+ resolve(defaultVal);
95
+ } else if (key === '\u001b') {
96
+ cleanup();
97
+ resolve(null);
98
+ } else if (key.toLowerCase() === 'y') {
99
+ cleanup();
100
+ resolve(true);
101
+ } else if (key.toLowerCase() === 'n') {
102
+ cleanup();
103
+ resolve(false);
104
+ }
105
+ }
106
+
107
+ function cleanup() {
108
+ process.stdin.removeListener('data', onData);
109
+ process.stdin.setRawMode(false);
110
+ process.stdin.pause();
111
+ rl.close();
112
+ }
113
+
114
+ process.stdin.on('data', onData);
115
+ });
116
+ }
117
+
118
+ function promptInput(message) {
119
+ return new Promise((resolve) => {
120
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
121
+ rl.question(` ${message}: `, (answer) => {
122
+ rl.close();
123
+ resolve(answer);
124
+ });
125
+ });
126
+ }
127
+
128
+ function pressAnyKey() {
129
+ return new Promise((resolve) => {
130
+ process.stdout.write(chalk.gray('\n 按任意鍵返回...'));
131
+ process.stdin.setRawMode(true);
132
+ process.stdin.resume();
133
+ process.stdin.once('data', () => {
134
+ process.stdin.setRawMode(false);
135
+ process.stdin.pause();
136
+ resolve();
137
+ });
138
+ });
14
139
  }
15
140
 
16
141
  async function mainMenu() {
@@ -22,31 +147,33 @@ async function mainMenu() {
22
147
 
23
148
  let running = true;
24
149
  while (running) {
25
- console.clear();
26
150
  const config = loadConfig();
27
151
 
152
+ console.clear();
28
153
  box([
29
154
  chalk.bold.white(`tronclass-auto v${pkg.version}`),
30
155
  chalk.gray('自動觀看 eclass/TronClass 影片'),
31
156
  '',
32
- `課程: ${chalk.cyan(config.courses.length + ' 個')} | Cookie: ${chalk.green('✓')} | 倍速: ${chalk.yellow(config.playbackRate || 2)}x`,
157
+ `課程: ${chalk.cyan(config.courses.length)} 個 | Cookie: ${chalk.green('✓')} | 倍速: ${chalk.yellow(config.playbackRate || 2)}x`,
33
158
  ]);
34
- console.log('');
35
-
36
- const { action } = await inquirer.prompt([{
37
- type: 'list',
38
- name: 'action',
39
- message: '選擇操作:',
40
- choices: [
41
- { name: chalk.green('▶ 開始自動觀看'), value: 'run' },
42
- { name: chalk.cyan('📋 管理課程列表'), value: 'courses' },
43
- { name: chalk.yellow('📊 查看進度統計'), value: 'status' },
44
- { name: chalk.magenta('⚙ 設定'), value: 'settings' },
45
- new inquirer.Separator(),
46
- { name: chalk.gray('🚪 離開'), value: 'exit' }
47
- ],
48
- pageSize: 10
49
- }]);
159
+
160
+ const action = await promptList('選擇操作:', [
161
+ { name: chalk.green('▶ 開始自動觀看'), value: 'run' },
162
+ { name: chalk.cyan('📋 管理課程列表'), value: 'courses' },
163
+ { name: chalk.yellow('📊 查看進度統計'), value: 'status' },
164
+ { name: chalk.magenta('⚙ 設定'), value: 'settings' },
165
+ '__sep__',
166
+ { name: chalk.gray('🚪 離開'), value: 'exit' }
167
+ ], [
168
+ '',
169
+ chalk.white('╔' + '═'.repeat(50) + '╗'),
170
+ chalk.white('║') + chalk.bold.white(` tronclass-auto v${pkg.version}`.padEnd(50)) + chalk.white('║'),
171
+ chalk.white('║') + chalk.gray(' 自動觀看 eclass/TronClass 影片'.padEnd(50)) + chalk.white('║'),
172
+ chalk.white('║') + ` 課程: ${chalk.cyan(config.courses.length)} 個 | Cookie: ${chalk.green('✓')} | 倍速: ${chalk.yellow(config.playbackRate || 2)}x`.padEnd(50) + chalk.white('║'),
173
+ chalk.white('╚' + '═'.repeat(50) + '╝'),
174
+ ]);
175
+
176
+ if (action === '__esc__') { running = false; continue; }
50
177
 
51
178
  switch (action) {
52
179
  case 'run': await runMenu(config); break;
@@ -71,20 +198,11 @@ async function runMenu(config) {
71
198
  return;
72
199
  }
73
200
 
74
- const { confirm } = await inquirer.prompt([{
75
- type: 'confirm',
76
- name: 'confirm',
77
- message: `開始觀看全部 ${config.courses.length} 個課程?`,
78
- default: true
79
- }]);
80
- if (!confirm) return;
81
-
82
- const { headless } = await inquirer.prompt([{
83
- type: 'confirm',
84
- name: 'headless',
85
- message: '無頭模式(背景執行)?',
86
- default: false
87
- }]);
201
+ const confirm = await promptConfirm(`開始觀看全部 ${config.courses.length} 個課程?`);
202
+ if (confirm === null) return;
203
+
204
+ const headless = await promptConfirm('無頭模式(背景執行)?', false);
205
+ if (headless === null) return;
88
206
 
89
207
  console.log('');
90
208
  box([
@@ -107,72 +225,99 @@ async function courseMenu() {
107
225
  const courses = config.courses || [];
108
226
 
109
227
  console.clear();
110
- box([
111
- chalk.bold.white('📋 課程列表'),
112
- ...(courses.length === 0
113
- ? [chalk.gray(' 還沒有課程')]
114
- : courses.map((url, i) => {
228
+
229
+ let courseNames = {};
230
+ if (courses.length > 0) {
231
+ try {
232
+ const { chromium } = require('playwright');
233
+ const cookies = loadCookies();
234
+ if (cookies.length > 0) {
235
+ const browser = await chromium.launch({ headless: true, slowMo: 50 });
236
+ const ctx = await browser.newContext();
237
+ await ctx.addCookies(cookies);
238
+
239
+ const results = await Promise.all(courses.map(async (url) => {
115
240
  const match = url.match(/\/course\/(\d+)\//);
116
- const id = match ? match[1] : '?';
117
- return ` ${chalk.white(i + 1 + '.')} [${chalk.cyan(id)}] ${chalk.gray(url.substring(0, 45))}`;
118
- })),
241
+ if (!match) return { id: '?', name: '' };
242
+ const pg = await ctx.newPage();
243
+ try {
244
+ await pg.goto(`${BASE_URL}/course/${match[1]}/content#/`, { waitUntil: 'domcontentloaded', timeout: 8000 });
245
+ await pg.waitForTimeout(2000);
246
+ const name = await pg.evaluate(() => {
247
+ const el = document.querySelector('.learning-activities');
248
+ if (!el) return '';
249
+ const scope = angular.element(el).scope();
250
+ return (scope && scope.course) ? (scope.course.name || '') : '';
251
+ });
252
+ return { id: match[1], name: name.substring(0, 30) };
253
+ } catch (e) {
254
+ return { id: match[1], name: '' };
255
+ } finally {
256
+ await pg.close();
257
+ }
258
+ }));
259
+
260
+ results.forEach(r => { if (r.name) courseNames[r.id] = r.name; });
261
+ await browser.close();
262
+ }
263
+ } catch (e) {}
264
+ }
265
+
266
+ const choices = [
267
+ { name: chalk.green('➕ 新增課程'), value: 'add' },
268
+ '__sep__',
269
+ ];
270
+ courses.forEach((url, i) => {
271
+ const match = url.match(/\/course\/(\d+)\//);
272
+ const id = match ? match[1] : '?';
273
+ const name = courseNames[id] || id;
274
+ choices.push({ name: `${chalk.red('✕')} 移除 ${name} [${id}]`, value: `remove_${i}` });
275
+ });
276
+ if (courses.length > 0) {
277
+ choices.push({ name: chalk.red('🗑 清空所有'), value: 'clear' });
278
+ }
279
+ choices.push('__sep__');
280
+ choices.push({ name: chalk.gray('⬅ 返回'), value: 'back' });
281
+
282
+ const action = await promptList(`課程管理 (${courses.length} 個):`, choices, [
119
283
  '',
120
- chalk.gray(' 新增: tronclass course --add <ID或URL>'),
121
- chalk.gray(' 移除: tronclass course --remove <編號>'),
284
+ chalk.white('┌' + '─'.repeat(50) + '┐'),
285
+ chalk.white('│') + chalk.bold.white(' 📋 課程列表'.padEnd(50)) + chalk.white('│'),
286
+ chalk.white('├' + '─'.repeat(50) + '┤'),
287
+ ...courses.map((url, i) => {
288
+ const match = url.match(/\/course\/(\d+)\//);
289
+ const id = match ? match[1] : '?';
290
+ const name = courseNames[id] || id;
291
+ return chalk.white('│') + ` ${chalk.white(i + 1 + '.')} ${chalk.cyan(name)} [${id}]`.padEnd(51) + chalk.white('│');
292
+ }),
293
+ chalk.white('└' + '─'.repeat(50) + '┘'),
122
294
  ]);
123
- console.log('');
124
-
125
- const { action } = await inquirer.prompt([{
126
- type: 'list',
127
- name: 'action',
128
- message: `課程管理 (${courses.length} 個)`,
129
- choices: [
130
- { name: chalk.green('➕ 新增課程(互動式)'), value: 'add' },
131
- ...(courses.length > 0 ? [
132
- ...courses.map((url, i) => {
133
- const match = url.match(/\/course\/(\d+)\//);
134
- const id = match ? match[1] : '?';
135
- return { name: `${chalk.red('✕')} [${id}] 移除`, value: `remove_${i}` };
136
- }),
137
- new inquirer.Separator(),
138
- { name: chalk.red('🗑 清空所有'), value: 'clear' }
139
- ] : []),
140
- new inquirer.Separator(),
141
- { name: chalk.gray('⬅ 返回'), value: 'back' }
142
- ],
143
- pageSize: 20
144
- }]);
145
-
146
- if (action === 'back') {
295
+
296
+ if (action === '__esc__' || action === 'back') {
147
297
  editing = false;
148
298
  } else if (action === 'add') {
149
- const { input } = await inquirer.prompt([{
150
- type: 'input',
151
- name: 'input',
152
- message: '輸入課程 ID(如 127331)或完整 URL:',
153
- validate: (v) => v.trim().length > 0 ? true : '不可為空'
154
- }]);
155
- const config2 = loadConfig();
156
- config2.courses = config2.courses || [];
157
- config2.courses.push(normalizeUrl(input.trim()));
158
- saveConfig(config2);
159
- console.log(chalk.green(' 已新增!'));
299
+ const input = await promptInput('輸入課程 ID 或 URL');
300
+ if (input && input.trim()) {
301
+ config.courses = config.courses || [];
302
+ config.courses.push(normalizeUrl(input.trim()));
303
+ saveConfig(config);
304
+ console.log(chalk.green(' 已新增!'));
305
+ await pressAnyKey();
306
+ }
160
307
  } else if (action === 'clear') {
161
- const { confirm } = await inquirer.prompt([{
162
- type: 'confirm', name: 'confirm', message: '確定要清空所有課程?', default: false
163
- }]);
308
+ const confirm = await promptConfirm('確定要清空所有課程?', false);
164
309
  if (confirm) {
165
- const config2 = loadConfig();
166
- config2.courses = [];
167
- saveConfig(config2);
310
+ config.courses = [];
311
+ saveConfig(config);
168
312
  console.log(chalk.green(' 已清空'));
313
+ await pressAnyKey();
169
314
  }
170
- } else if (action.startsWith('remove_')) {
315
+ } else if (typeof action === 'string' && action.startsWith('remove_')) {
171
316
  const idx = parseInt(action.split('_')[1]);
172
- const config2 = loadConfig();
173
- const removed = config2.courses.splice(idx, 1)[0];
174
- saveConfig(config2);
317
+ const removed = config.courses.splice(idx, 1)[0];
318
+ saveConfig(config);
175
319
  console.log(chalk.green(` 已移除: ${removed}`));
320
+ await pressAnyKey();
176
321
  }
177
322
  }
178
323
  }
@@ -180,48 +325,46 @@ async function courseMenu() {
180
325
  async function settingsMenu() {
181
326
  const config = loadConfig();
182
327
 
183
- const { setting } = await inquirer.prompt([{
184
- type: 'list',
185
- name: 'setting',
186
- message: '設定',
187
- choices: [
188
- { name: `倍速: ${chalk.cyan(config.playbackRate || 2)}x`, value: 'speed' },
189
- { name: `無頭模式: ${chalk.cyan(config.headless ? '是' : '否')}`, value: 'headless' },
190
- { name: `SlowMo: ${chalk.cyan(config.slowMo || 50)}ms`, value: 'slowmo' },
191
- new inquirer.Separator(),
192
- { name: chalk.gray('⬅ 返回'), value: 'back' }
193
- ]
194
- }]);
195
-
196
- if (setting === 'back') return;
328
+ const setting = await promptList('設定:', [
329
+ { name: `倍速: ${chalk.cyan(config.playbackRate || 2)}x`, value: 'speed' },
330
+ { name: `無頭模式: ${chalk.cyan(config.headless ? '是' : '否')}`, value: 'headless' },
331
+ { name: `SlowMo: ${chalk.cyan(config.slowMo || 50)}ms`, value: 'slowmo' },
332
+ '__sep__',
333
+ { name: chalk.gray('⬅ 返回'), value: 'back' }
334
+ ], [
335
+ '',
336
+ chalk.white('╔' + '═'.repeat(50) + '╗'),
337
+ chalk.white('║') + chalk.bold.white(' ⚙ 設定'.padEnd(50)) + chalk.white('║'),
338
+ chalk.white('╚' + '═'.repeat(50) + '╝'),
339
+ ]);
340
+
341
+ if (setting === '__esc__' || setting === 'back') return;
197
342
 
198
343
  if (setting === 'speed') {
199
- const { speed } = await inquirer.prompt([{
200
- type: 'list', name: 'speed', message: '選擇播放倍速:',
201
- choices: [
202
- { name: '1x(正常)', value: 1 },
203
- { name: '1.5x', value: 1.5 },
204
- { name: '2x(推薦)', value: 2 },
205
- { name: '4x', value: 4 },
206
- { name: '8x', value: 8 }
207
- ],
208
- default: config.playbackRate || 2
209
- }]);
210
- config.playbackRate = speed;
211
- saveConfig(config);
212
- console.log(chalk.green(` 已設定為 ${speed}x`));
344
+ const speed = await promptList('選擇播放倍速:', [
345
+ { name: '2x', value: 2 },
346
+ { name: '1.5x', value: 1.5 },
347
+ { name: '1.25x', value: 1.25 },
348
+ { name: '1x', value: 1 },
349
+ { name: '0.75x', value: 0.75 },
350
+ ]);
351
+ if (speed !== '__esc__') {
352
+ config.playbackRate = speed;
353
+ saveConfig(config);
354
+ console.log(chalk.green(` 已設定為 ${speed}x`));
355
+ }
213
356
  } else if (setting === 'headless') {
214
- const { hl } = await inquirer.prompt([{
215
- type: 'confirm', name: 'hl', message: '啟用無頭模式?', default: config.headless || false
216
- }]);
217
- config.headless = hl;
218
- saveConfig(config);
357
+ const hl = await promptConfirm('啟用無頭模式?', config.headless || false);
358
+ if (hl !== null) {
359
+ config.headless = hl;
360
+ saveConfig(config);
361
+ }
219
362
  } else if (setting === 'slowmo') {
220
- const { ms } = await inquirer.prompt([{
221
- type: 'number', name: 'ms', message: 'SlowMo 毫秒數:', default: config.slowMo || 50
222
- }]);
223
- config.slowMo = ms;
224
- saveConfig(config);
363
+ const ms = await promptInput('SlowMo 毫秒數');
364
+ if (ms && !isNaN(parseInt(ms))) {
365
+ config.slowMo = parseInt(ms);
366
+ saveConfig(config);
367
+ }
225
368
  }
226
369
  }
227
370
 
@@ -235,8 +378,4 @@ function normalizeUrl(input) {
235
378
  return input;
236
379
  }
237
380
 
238
- async function pressAnyKey() {
239
- await inquirer.prompt([{ type: 'input', name: '_', message: chalk.gray('按 Enter 返回...') }]);
240
- }
241
-
242
381
  module.exports = { mainMenu };
package/src/video.js CHANGED
@@ -119,17 +119,30 @@ async function watchYouTube(page, stats) {
119
119
 
120
120
  await page.waitForTimeout(2000);
121
121
 
122
- const ytDuration = await page.evaluate(() => {
123
- const actText = document.querySelector('.activity-attribute, [class*="attribute"]');
124
- if (actText) {
125
- const m = actText.textContent.match(/(\d{2}):(\d{2}):(\d{2})/);
126
- if (m) return parseInt(m[1]) * 3600 + parseInt(m[2]) * 60 + parseInt(m[3]);
127
- }
128
- return 180;
129
- });
122
+ let ytDuration = 0;
123
+ for (let i = 0; i < 10; i++) {
124
+ try {
125
+ const freshFrame = page.frame({ url: /youtube|youtu\.be/ });
126
+ if (freshFrame) {
127
+ ytDuration = await freshFrame.evaluate(() => document.querySelector('#movie_player').getDuration());
128
+ if (ytDuration > 0) break;
129
+ }
130
+ } catch (e) {}
131
+ await page.waitForTimeout(2000);
132
+ }
133
+ if (!ytDuration || ytDuration <= 0) {
134
+ ytDuration = await page.evaluate(() => {
135
+ const actText = document.querySelector('.activity-attribute, [class*="attribute"]');
136
+ if (actText) {
137
+ const m = actText.textContent.match(/(\d{2}):(\d{2}):(\d{2})/);
138
+ if (m) return parseInt(m[1]) * 3600 + parseInt(m[2]) * 60 + parseInt(m[3]);
139
+ }
140
+ return 180;
141
+ });
142
+ }
130
143
 
131
144
  const waitTime = (rate >= 2 ? Math.ceil(ytDuration / 2) : ytDuration) + 30;
132
- log(chalk.blue(` ~${formatTime(ytDuration)} video → ~${formatTime(waitTime)} (${rate}x)`));
145
+ log(chalk.blue(` ${formatTime(ytDuration)} video → ~${formatTime(waitTime)} (${rate}x)`));
133
146
 
134
147
  if (stats) {
135
148
  stats.currentVideoDuration = ytDuration;