tronclass-auto 1.6.0 → 1.7.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tronclass-auto",
3
- "version": "1.6.0",
3
+ "version": "1.7.0",
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();
@@ -251,23 +297,27 @@ async function showStatus() {
251
297
  const match = courseUrl.match(/\/course\/(\d+)\//);
252
298
  const courseId = match ? match[1] : '?';
253
299
  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;
300
+ const allVideos = await getAllVideos(page, courseId);
301
+ const videos = allVideos.filter(u => u.type === 'online_video');
302
+ const completed = videos.filter(v => v.completeness === 'full').length;
303
+ const future = videos.filter(v => v.isFuture && v.completeness !== 'full').length;
304
+ const remaining = videos.filter(v => v.completeness !== 'full' && !v.isFuture).length;
305
+ const exams = allVideos.filter(u => u.type === 'exam' && u.completeness !== 'full').length;
258
306
  totalVideos += videos.length;
259
- totalRemaining += remaining.length;
307
+ totalRemaining += remaining;
260
308
  totalExams += exams;
261
309
 
262
- const pct = videos.length > 0 ? Math.floor(((videos.length - remaining.length) / videos.length) * 100) : 100;
310
+ const pct = videos.length > 0 ? Math.floor((completed / videos.length) * 100) : 100;
263
311
  const filled = Math.floor(50 * pct / 100);
264
312
  const empty = 50 - filled;
265
313
  const bar = pct === 100
266
314
  ? chalk.green('█'.repeat(50))
267
315
  : chalk.green('█'.repeat(filled)) + chalk.gray('░'.repeat(empty));
268
316
 
269
- const doneCount = videos.length - remaining.length;
270
- console.log(chalk.cyan('│') + ` [${courseId}] ${doneCount}/${videos.length} videos ${exams} exams`.padEnd(50) + chalk.cyan('│'));
317
+ const doneCount = completed;
318
+ const pending = remaining;
319
+ const info = future > 0 ? ` (${future} 未開放)` : '';
320
+ console.log(chalk.cyan('│') + ` [${courseId}] ${doneCount}/${videos.length} videos ${exams} exams${info}`.padEnd(50) + chalk.cyan('│'));
271
321
  console.log(chalk.cyan('│') + ` ${bar} ${pct}%`.padEnd(52) + chalk.cyan('│'));
272
322
  } catch (e) {
273
323
  console.log(chalk.cyan('│') + ` [${courseId}] ERROR`.padEnd(50) + chalk.cyan('│'));
package/src/tui.js CHANGED
@@ -4,6 +4,49 @@ const { loadConfig, saveConfig, BASE_URL } = require('./config');
4
4
  const { loadCookies } = require('./auth');
5
5
  const pkg = require('../package.json');
6
6
 
7
+ async function getCourseName(page, courseId) {
8
+ try {
9
+ await page.goto(`${BASE_URL}/course/${courseId}/content#/`, {
10
+ waitUntil: 'domcontentloaded',
11
+ timeout: 15000
12
+ });
13
+ await page.waitForTimeout(3000);
14
+ return await page.evaluate(() => {
15
+ const el = document.querySelector('.learning-activities');
16
+ if (!el) return null;
17
+ const scope = angular.element(el).scope();
18
+ if (!scope) return null;
19
+ return scope.course ? scope.course.title || scope.course.name : null;
20
+ });
21
+ } catch (e) {
22
+ return null;
23
+ }
24
+ }
25
+
26
+ async function withEsc(promptFn, onEsc) {
27
+ let resolved = false;
28
+ process.stdin.setRawMode(true);
29
+ process.stdin.resume();
30
+ const escHandler = (data) => {
31
+ if (data.toString() === '\u001b' && !resolved) {
32
+ resolved = true;
33
+ process.stdin.removeListener('data', escHandler);
34
+ process.stdin.setRawMode(false);
35
+ process.stdin.pause();
36
+ onEsc();
37
+ }
38
+ };
39
+ process.stdin.on('data', escHandler);
40
+ try {
41
+ await promptFn();
42
+ } finally {
43
+ if (!resolved) {
44
+ process.stdin.removeListener('data', escHandler);
45
+ }
46
+ resolved = true;
47
+ }
48
+ }
49
+
7
50
  function box(lines, width = 52) {
8
51
  const top = '╔' + '═'.repeat(width - 2) + '╗';
9
52
  const bot = '╚' + '═'.repeat(width - 2) + '╝';
@@ -33,20 +76,36 @@ async function mainMenu() {
33
76
  ]);
34
77
  console.log('');
35
78
 
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
- }]);
79
+ const { action } = await new Promise((resolve) => {
80
+ process.stdin.setRawMode(true);
81
+ process.stdin.resume();
82
+ const escHandler = (data) => {
83
+ if (data.toString() === '\u001b') {
84
+ process.stdin.removeListener('data', escHandler);
85
+ process.stdin.setRawMode(false);
86
+ process.stdin.pause();
87
+ resolve({ action: 'exit' });
88
+ }
89
+ };
90
+ process.stdin.on('data', escHandler);
91
+ inquirer.prompt([{
92
+ type: 'list',
93
+ name: 'action',
94
+ message: '選擇操作:',
95
+ choices: [
96
+ { name: chalk.green('▶ 開始自動觀看'), value: 'run' },
97
+ { name: chalk.cyan('📋 管理課程列表'), value: 'courses' },
98
+ { name: chalk.yellow('📊 查看進度統計'), value: 'status' },
99
+ { name: chalk.magenta('⚙ 設定'), value: 'settings' },
100
+ new inquirer.Separator(),
101
+ { name: chalk.gray('🚪 離開'), value: 'exit' }
102
+ ],
103
+ pageSize: 10
104
+ }]).then(ans => {
105
+ process.stdin.removeListener('data', escHandler);
106
+ resolve(ans);
107
+ });
108
+ });
50
109
 
51
110
  switch (action) {
52
111
  case 'run': await runMenu(config); break;
@@ -71,20 +130,52 @@ async function runMenu(config) {
71
130
  return;
72
131
  }
73
132
 
74
- const { confirm } = await inquirer.prompt([{
75
- type: 'confirm',
76
- name: 'confirm',
77
- message: `開始觀看全部 ${config.courses.length} 個課程?`,
78
- default: true
79
- }]);
133
+ const { confirm } = await new Promise((resolve) => {
134
+ process.stdin.setRawMode(true);
135
+ process.stdin.resume();
136
+ const escHandler = (data) => {
137
+ if (data.toString() === '\u001b') {
138
+ process.stdin.removeListener('data', escHandler);
139
+ process.stdin.setRawMode(false);
140
+ process.stdin.pause();
141
+ resolve({ confirm: false });
142
+ }
143
+ };
144
+ process.stdin.on('data', escHandler);
145
+ inquirer.prompt([{
146
+ type: 'confirm',
147
+ name: 'confirm',
148
+ message: `開始觀看全部 ${config.courses.length} 個課程?`,
149
+ default: true
150
+ }]).then(ans => {
151
+ process.stdin.removeListener('data', escHandler);
152
+ resolve(ans);
153
+ });
154
+ });
80
155
  if (!confirm) return;
81
156
 
82
- const { headless } = await inquirer.prompt([{
83
- type: 'confirm',
84
- name: 'headless',
85
- message: '無頭模式(背景執行)?',
86
- default: false
87
- }]);
157
+ const { headless } = await new Promise((resolve) => {
158
+ process.stdin.setRawMode(true);
159
+ process.stdin.resume();
160
+ const escHandler = (data) => {
161
+ if (data.toString() === '\u001b') {
162
+ process.stdin.removeListener('data', escHandler);
163
+ process.stdin.setRawMode(false);
164
+ process.stdin.pause();
165
+ resolve({ headless: false });
166
+ }
167
+ };
168
+ process.stdin.on('data', escHandler);
169
+ inquirer.prompt([{
170
+ type: 'confirm',
171
+ name: 'headless',
172
+ message: '無頭模式(背景執行)?',
173
+ default: false
174
+ }]).then(ans => {
175
+ process.stdin.removeListener('data', escHandler);
176
+ resolve(ans);
177
+ });
178
+ });
88
179
 
89
180
  console.log('');
90
181
  box([
@@ -102,77 +193,181 @@ async function runMenu(config) {
102
193
 
103
194
  async function courseMenu() {
104
195
  let editing = true;
105
- while (editing) {
106
- const config = loadConfig();
107
- const courses = config.courses || [];
196
+ const { chromium } = require('playwright');
197
+ const { ensureBrowser } = require('./browser');
198
+ let browser = null;
108
199
 
109
- console.clear();
110
- box([
111
- chalk.bold.white('📋 課程列表'),
112
- ...(courses.length === 0
113
- ? [chalk.gray(' 還沒有課程')]
114
- : courses.map((url, i) => {
115
- 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
- })),
119
- '',
120
- chalk.gray(' 新增: tronclass course --add <ID或URL>'),
121
- chalk.gray(' 移除: tronclass course --remove <編號>'),
122
- ]);
123
- console.log('');
200
+ async function getPage() {
201
+ if (!browser) {
202
+ const { loadCookies } = require('./auth');
203
+ const cookies = loadCookies();
204
+ const exePath = await ensureBrowser();
205
+ const launchOpts = { headless: true, slowMo: 50, args: ['--disable-gpu', '--disable-software-rasterizer'] };
206
+ if (exePath) launchOpts.executablePath = exePath;
207
+ browser = await chromium.launch(launchOpts);
208
+ const context = await browser.newContext();
209
+ await context.addCookies(cookies);
210
+ return await context.newPage();
211
+ }
212
+ return browser.newPage();
213
+ }
124
214
 
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
- }),
215
+ let courseNames = {};
216
+
217
+ async function loadCourseNames(courses) {
218
+ console.log(chalk.gray(' 載入課程名稱...'));
219
+ const page = await getPage();
220
+ for (const url of courses) {
221
+ const match = url.match(/\/course\/(\d+)\//);
222
+ const id = match ? match[1] : null;
223
+ if (id && !courseNames[id]) {
224
+ const name = await getCourseName(page, id);
225
+ if (name) courseNames[id] = name;
226
+ }
227
+ }
228
+ try { await page.close(); } catch (e) {}
229
+ }
230
+
231
+ try {
232
+ while (editing) {
233
+ const config = loadConfig();
234
+ const courses = config.courses || [];
235
+
236
+ await loadCourseNames(courses);
237
+
238
+ console.clear();
239
+ box([
240
+ chalk.bold.white('📋 課程列表'),
241
+ ...(courses.length === 0
242
+ ? [chalk.gray(' 還沒有課程')]
243
+ : courses.map((url, i) => {
244
+ const match = url.match(/\/course\/(\d+)\//);
245
+ const id = match ? match[1] : '?';
246
+ const name = courseNames[id] || null;
247
+ if (name) {
248
+ return ` ${chalk.white(i + 1 + '.')} ${chalk.cyan(name)} ${chalk.gray(`[${id}]`)}`;
249
+ }
250
+ return ` ${chalk.white(i + 1 + '.')} [${chalk.cyan(id)}] ${chalk.gray(url.substring(0, 45))}`;
251
+ })),
252
+ '',
253
+ chalk.gray(' 新增: tronclass course --add <ID或URL>'),
254
+ chalk.gray(' 移除: tronclass course --remove <編號>'),
255
+ chalk.gray(' ESC 返回'),
256
+ ]);
257
+ console.log('');
258
+
259
+ const { action } = await new Promise((resolve) => {
260
+ const listChoices = [
261
+ { name: chalk.green('➕ 新增課程(互動式)'), value: 'add' },
262
+ ...(courses.length > 0 ? [
263
+ ...courses.map((url, i) => {
264
+ const match = url.match(/\/course\/(\d+)\//);
265
+ const id = match ? match[1] : '?';
266
+ const name = courseNames[id] || null;
267
+ const label = name ? `${name} [${id}]` : `[${id}]`;
268
+ return { name: `${chalk.red('✕')} 移除 ${label}`, value: `remove_${i}` };
269
+ }),
270
+ new inquirer.Separator(),
271
+ { name: chalk.red('🗑 清空所有'), value: 'clear' }
272
+ ] : []),
137
273
  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') {
147
- editing = false;
148
- } 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(' 已新增!'));
160
- } else if (action === 'clear') {
161
- const { confirm } = await inquirer.prompt([{
162
- type: 'confirm', name: 'confirm', message: '確定要清空所有課程?', default: false
163
- }]);
164
- if (confirm) {
274
+ { name: chalk.gray('⬅ 返回'), value: 'back' }
275
+ ];
276
+
277
+ process.stdin.setRawMode(true);
278
+ process.stdin.resume();
279
+ const escHandler = (data) => {
280
+ if (data.toString() === '\u001b') {
281
+ process.stdin.removeListener('data', escHandler);
282
+ process.stdin.setRawMode(false);
283
+ process.stdin.pause();
284
+ resolve({ action: 'back' });
285
+ }
286
+ };
287
+ process.stdin.on('data', escHandler);
288
+
289
+ inquirer.prompt([{
290
+ type: 'list',
291
+ name: 'action',
292
+ message: `課程管理 (${courses.length} 個)`,
293
+ choices: listChoices,
294
+ pageSize: 20
295
+ }]).then(ans => {
296
+ process.stdin.removeListener('data', escHandler);
297
+ resolve(ans);
298
+ });
299
+ });
300
+
301
+ if (action === 'back') {
302
+ editing = false;
303
+ } else if (action === 'add') {
304
+ const { input } = await new Promise((resolve) => {
305
+ process.stdin.setRawMode(true);
306
+ process.stdin.resume();
307
+ const escHandler = (data) => {
308
+ if (data.toString() === '\u001b') {
309
+ process.stdin.removeListener('data', escHandler);
310
+ process.stdin.setRawMode(false);
311
+ process.stdin.pause();
312
+ resolve({ input: '' });
313
+ }
314
+ };
315
+ process.stdin.on('data', escHandler);
316
+ inquirer.prompt([{
317
+ type: 'input',
318
+ name: 'input',
319
+ message: '輸入課程 ID(如 127331)或完整 URL:',
320
+ validate: (v) => v.trim().length > 0 ? true : '不可為空'
321
+ }]).then(ans => {
322
+ process.stdin.removeListener('data', escHandler);
323
+ resolve(ans);
324
+ });
325
+ });
326
+ if (input) {
327
+ const config2 = loadConfig();
328
+ config2.courses = config2.courses || [];
329
+ config2.courses.push(normalizeUrl(input.trim()));
330
+ saveConfig(config2);
331
+ console.log(chalk.green(' 已新增!'));
332
+ }
333
+ } else if (action === 'clear') {
334
+ const { confirm } = await new Promise((resolve) => {
335
+ process.stdin.setRawMode(true);
336
+ process.stdin.resume();
337
+ const escHandler = (data) => {
338
+ if (data.toString() === '\u001b') {
339
+ process.stdin.removeListener('data', escHandler);
340
+ process.stdin.setRawMode(false);
341
+ process.stdin.pause();
342
+ resolve({ confirm: false });
343
+ }
344
+ };
345
+ process.stdin.on('data', escHandler);
346
+ inquirer.prompt([{
347
+ type: 'confirm', name: 'confirm', message: '確定要清空所有課程?', default: false
348
+ }]).then(ans => {
349
+ process.stdin.removeListener('data', escHandler);
350
+ resolve(ans);
351
+ });
352
+ });
353
+ if (confirm) {
354
+ const config2 = loadConfig();
355
+ config2.courses = [];
356
+ saveConfig(config2);
357
+ console.log(chalk.green(' 已清空'));
358
+ }
359
+ } else if (action.startsWith('remove_')) {
360
+ const idx = parseInt(action.split('_')[1]);
165
361
  const config2 = loadConfig();
166
- config2.courses = [];
362
+ const removed = config2.courses.splice(idx, 1)[0];
167
363
  saveConfig(config2);
168
- console.log(chalk.green(' 已清空'));
364
+ courseNames = {};
365
+ console.log(chalk.green(` 已移除: ${removed}`));
169
366
  }
170
- } else if (action.startsWith('remove_')) {
171
- const idx = parseInt(action.split('_')[1]);
172
- const config2 = loadConfig();
173
- const removed = config2.courses.splice(idx, 1)[0];
174
- saveConfig(config2);
175
- console.log(chalk.green(` 已移除: ${removed}`));
367
+ }
368
+ } finally {
369
+ if (browser) {
370
+ try { await browser.close(); } catch (e) {}
176
371
  }
177
372
  }
178
373
  }
@@ -180,46 +375,110 @@ async function courseMenu() {
180
375
  async function settingsMenu() {
181
376
  const config = loadConfig();
182
377
 
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
- }]);
378
+ const { setting } = await new Promise((resolve) => {
379
+ process.stdin.setRawMode(true);
380
+ process.stdin.resume();
381
+ const escHandler = (data) => {
382
+ if (data.toString() === '\u001b') {
383
+ process.stdin.removeListener('data', escHandler);
384
+ process.stdin.setRawMode(false);
385
+ process.stdin.pause();
386
+ resolve({ setting: 'back' });
387
+ }
388
+ };
389
+ process.stdin.on('data', escHandler);
390
+ inquirer.prompt([{
391
+ type: 'list',
392
+ name: 'setting',
393
+ message: '設定',
394
+ choices: [
395
+ { name: `倍速: ${chalk.cyan(config.playbackRate || 2)}x`, value: 'speed' },
396
+ { name: `無頭模式: ${chalk.cyan(config.headless ? '是' : '否')}`, value: 'headless' },
397
+ { name: `SlowMo: ${chalk.cyan(config.slowMo || 50)}ms`, value: 'slowmo' },
398
+ new inquirer.Separator(),
399
+ { name: chalk.gray('⬅ 返回'), value: 'back' }
400
+ ]
401
+ }]).then(ans => {
402
+ process.stdin.removeListener('data', escHandler);
403
+ resolve(ans);
404
+ });
405
+ });
195
406
 
196
407
  if (setting === 'back') return;
197
408
 
198
409
  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
- }]);
410
+ const { speed } = await new Promise((resolve) => {
411
+ process.stdin.setRawMode(true);
412
+ process.stdin.resume();
413
+ const escHandler = (data) => {
414
+ if (data.toString() === '\u001b') {
415
+ process.stdin.removeListener('data', escHandler);
416
+ process.stdin.setRawMode(false);
417
+ process.stdin.pause();
418
+ resolve({ speed: config.playbackRate || 2 });
419
+ }
420
+ };
421
+ process.stdin.on('data', escHandler);
422
+ inquirer.prompt([{
423
+ type: 'list', name: 'speed', message: '選擇播放倍速:',
424
+ choices: [
425
+ { name: '1x(正常)', value: 1 },
426
+ { name: '1.5x', value: 1.5 },
427
+ { name: '2x(推薦)', value: 2 },
428
+ { name: '4x', value: 4 },
429
+ { name: '8x', value: 8 }
430
+ ],
431
+ default: config.playbackRate || 2
432
+ }]).then(ans => {
433
+ process.stdin.removeListener('data', escHandler);
434
+ resolve(ans);
435
+ });
436
+ });
210
437
  config.playbackRate = speed;
211
438
  saveConfig(config);
212
439
  console.log(chalk.green(` 已設定為 ${speed}x`));
213
440
  } else if (setting === 'headless') {
214
- const { hl } = await inquirer.prompt([{
215
- type: 'confirm', name: 'hl', message: '啟用無頭模式?', default: config.headless || false
216
- }]);
441
+ const { hl } = await new Promise((resolve) => {
442
+ process.stdin.setRawMode(true);
443
+ process.stdin.resume();
444
+ const escHandler = (data) => {
445
+ if (data.toString() === '\u001b') {
446
+ process.stdin.removeListener('data', escHandler);
447
+ process.stdin.setRawMode(false);
448
+ process.stdin.pause();
449
+ resolve({ hl: config.headless || false });
450
+ }
451
+ };
452
+ process.stdin.on('data', escHandler);
453
+ inquirer.prompt([{
454
+ type: 'confirm', name: 'hl', message: '啟用無頭模式?', default: config.headless || false
455
+ }]).then(ans => {
456
+ process.stdin.removeListener('data', escHandler);
457
+ resolve(ans);
458
+ });
459
+ });
217
460
  config.headless = hl;
218
461
  saveConfig(config);
219
462
  } else if (setting === 'slowmo') {
220
- const { ms } = await inquirer.prompt([{
221
- type: 'number', name: 'ms', message: 'SlowMo 毫秒數:', default: config.slowMo || 50
222
- }]);
463
+ const { ms } = await new Promise((resolve) => {
464
+ process.stdin.setRawMode(true);
465
+ process.stdin.resume();
466
+ const escHandler = (data) => {
467
+ if (data.toString() === '\u001b') {
468
+ process.stdin.removeListener('data', escHandler);
469
+ process.stdin.setRawMode(false);
470
+ process.stdin.pause();
471
+ resolve({ ms: config.slowMo || 50 });
472
+ }
473
+ };
474
+ process.stdin.on('data', escHandler);
475
+ inquirer.prompt([{
476
+ type: 'number', name: 'ms', message: 'SlowMo 毫秒數:', default: config.slowMo || 50
477
+ }]).then(ans => {
478
+ process.stdin.removeListener('data', escHandler);
479
+ resolve(ans);
480
+ });
481
+ });
223
482
  config.slowMo = ms;
224
483
  saveConfig(config);
225
484
  }
@@ -236,7 +495,21 @@ function normalizeUrl(input) {
236
495
  }
237
496
 
238
497
  async function pressAnyKey() {
239
- await inquirer.prompt([{ type: 'input', name: '_', message: chalk.gray('按 Enter 返回...') }]);
498
+ return new Promise((resolve) => {
499
+ process.stdin.setRawMode(true);
500
+ process.stdin.resume();
501
+ const handler = (data) => {
502
+ const str = data.toString();
503
+ if (str === '\u001b' || str === '\r' || str === '\n') {
504
+ process.stdin.removeListener('data', handler);
505
+ process.stdin.setRawMode(false);
506
+ process.stdin.pause();
507
+ resolve();
508
+ }
509
+ };
510
+ process.stdin.on('data', handler);
511
+ process.stdout.write(chalk.gray('按 Enter 或 ESC 返回...'));
512
+ });
240
513
  }
241
514
 
242
515
  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;