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 +1 -1
- package/src/index.js +74 -13
- package/src/tui.js +275 -136
- package/src/video.js +22 -9
package/package.json
CHANGED
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
|
|
233
|
-
await
|
|
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 (
|
|
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
|
-
|
|
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
|
|
255
|
-
const
|
|
256
|
-
|
|
257
|
-
|
|
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
|
|
318
|
+
totalRemaining += remaining;
|
|
260
319
|
totalExams += exams;
|
|
261
320
|
|
|
262
|
-
const pct = videos.length > 0 ? Math.floor((
|
|
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 =
|
|
270
|
-
|
|
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
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
|
157
|
+
`課程: ${chalk.cyan(config.courses.length)} 個 | Cookie: ${chalk.green('✓')} | 倍速: ${chalk.yellow(config.playbackRate || 2)}x`,
|
|
33
158
|
]);
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
name: '
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
117
|
-
|
|
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.
|
|
121
|
-
chalk.
|
|
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
|
-
|
|
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
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
|
162
|
-
type: 'confirm', name: 'confirm', message: '確定要清空所有課程?', default: false
|
|
163
|
-
}]);
|
|
308
|
+
const confirm = await promptConfirm('確定要清空所有課程?', false);
|
|
164
309
|
if (confirm) {
|
|
165
|
-
|
|
166
|
-
|
|
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
|
|
173
|
-
|
|
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
|
|
184
|
-
|
|
185
|
-
name: '
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
const
|
|
126
|
-
if (
|
|
127
|
-
|
|
128
|
-
|
|
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(`
|
|
145
|
+
log(chalk.blue(` ${formatTime(ytDuration)} video → ~${formatTime(waitTime)} (${rate}x)`));
|
|
133
146
|
|
|
134
147
|
if (stats) {
|
|
135
148
|
stats.currentVideoDuration = ytDuration;
|