tronclass-auto 1.5.6 → 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 +1 -1
- package/src/index.js +69 -18
- package/src/tui.js +394 -121
- 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) {}
|
|
@@ -110,7 +112,7 @@ async function processCourse(page, courseId, stats) {
|
|
|
110
112
|
try {
|
|
111
113
|
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 15000 });
|
|
112
114
|
} catch (e) {}
|
|
113
|
-
await page.waitForTimeout(
|
|
115
|
+
await page.waitForTimeout(15000);
|
|
114
116
|
|
|
115
117
|
if (!page.url().includes('learning-activity')) {
|
|
116
118
|
console.log(chalk.yellow(' Page redirect, skipping'));
|
|
@@ -155,13 +157,15 @@ async function processCourse(page, courseId, stats) {
|
|
|
155
157
|
}
|
|
156
158
|
const success = await watchVideo(page, stats);
|
|
157
159
|
if (success) {
|
|
160
|
+
console.log(chalk.gray(' Waiting for platform tracking...'));
|
|
161
|
+
await page.waitForTimeout(30000);
|
|
162
|
+
|
|
158
163
|
let serverComplete = false;
|
|
159
|
-
|
|
160
|
-
await page.waitForTimeout(10000);
|
|
164
|
+
try {
|
|
161
165
|
await page.goto(`${BASE_URL}/course/${courseId}/content#/`, {
|
|
162
|
-
waitUntil: 'domcontentloaded', timeout:
|
|
163
|
-
})
|
|
164
|
-
await page.waitForTimeout(
|
|
166
|
+
waitUntil: 'domcontentloaded', timeout: 20000
|
|
167
|
+
});
|
|
168
|
+
await page.waitForTimeout(8000);
|
|
165
169
|
|
|
166
170
|
serverComplete = await page.evaluate((actId) => {
|
|
167
171
|
const el = document.querySelector('.learning-activities');
|
|
@@ -178,9 +182,8 @@ async function processCourse(page, courseId, stats) {
|
|
|
178
182
|
}
|
|
179
183
|
return false;
|
|
180
184
|
}, act.id);
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
console.log(chalk.gray(` Checking server... (${attempt + 1}/5)`));
|
|
185
|
+
} catch (e) {
|
|
186
|
+
console.log(chalk.gray(` Server check error: ${e.message.substring(0, 40)}`));
|
|
184
187
|
}
|
|
185
188
|
|
|
186
189
|
if (serverComplete) {
|
|
@@ -188,7 +191,7 @@ async function processCourse(page, courseId, stats) {
|
|
|
188
191
|
done++;
|
|
189
192
|
console.log(chalk.green(' ✓ Saved (server confirmed)'));
|
|
190
193
|
} else {
|
|
191
|
-
console.log(chalk.yellow(' Server not confirmed
|
|
194
|
+
console.log(chalk.yellow(' Server not confirmed, skipping'));
|
|
192
195
|
triedIds.add(act.id);
|
|
193
196
|
fail++;
|
|
194
197
|
}
|
|
@@ -210,6 +213,50 @@ async function processCourse(page, courseId, stats) {
|
|
|
210
213
|
return { done, skip, fail };
|
|
211
214
|
}
|
|
212
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
|
+
|
|
213
260
|
async function showStatus() {
|
|
214
261
|
const config = loadConfig();
|
|
215
262
|
const cookies = loadCookies();
|
|
@@ -250,23 +297,27 @@ async function showStatus() {
|
|
|
250
297
|
const match = courseUrl.match(/\/course\/(\d+)\//);
|
|
251
298
|
const courseId = match ? match[1] : '?';
|
|
252
299
|
try {
|
|
253
|
-
const
|
|
254
|
-
const videos =
|
|
255
|
-
const
|
|
256
|
-
const
|
|
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;
|
|
257
306
|
totalVideos += videos.length;
|
|
258
|
-
totalRemaining += remaining
|
|
307
|
+
totalRemaining += remaining;
|
|
259
308
|
totalExams += exams;
|
|
260
309
|
|
|
261
|
-
const pct = videos.length > 0 ? Math.floor((
|
|
310
|
+
const pct = videos.length > 0 ? Math.floor((completed / videos.length) * 100) : 100;
|
|
262
311
|
const filled = Math.floor(50 * pct / 100);
|
|
263
312
|
const empty = 50 - filled;
|
|
264
313
|
const bar = pct === 100
|
|
265
314
|
? chalk.green('█'.repeat(50))
|
|
266
315
|
: chalk.green('█'.repeat(filled)) + chalk.gray('░'.repeat(empty));
|
|
267
316
|
|
|
268
|
-
const doneCount =
|
|
269
|
-
|
|
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('│'));
|
|
270
321
|
console.log(chalk.cyan('│') + ` ${bar} ${pct}%`.padEnd(52) + chalk.cyan('│'));
|
|
271
322
|
} catch (e) {
|
|
272
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
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
196
|
+
const { chromium } = require('playwright');
|
|
197
|
+
const { ensureBrowser } = require('./browser');
|
|
198
|
+
let browser = null;
|
|
108
199
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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.
|
|
139
|
-
]
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
}
|
|
164
|
-
|
|
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
|
-
|
|
364
|
+
courseNames = {};
|
|
365
|
+
console.log(chalk.green(` 已移除: ${removed}`));
|
|
169
366
|
}
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
|
215
|
-
|
|
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
|
|
221
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|