tronclass-auto 1.0.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/README.md +75 -0
- package/bin/eclass.js +57 -0
- package/package.json +21 -0
- package/src/auth.js +133 -0
- package/src/config.js +82 -0
- package/src/index.js +210 -0
- package/src/video.js +111 -0
package/README.md
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# tronclass-auto
|
|
2
|
+
|
|
3
|
+
自動觀看 eclass / TronClass 影片、填寫表單的 CLI 工具。
|
|
4
|
+
|
|
5
|
+
## 安裝
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install -g tronclass-auto
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## 使用方法
|
|
12
|
+
|
|
13
|
+
### 自動登入取得 Cookie
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
tronclass login
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
會自動開啟瀏覽器,手動登入後 Cookie 自動儲存。
|
|
20
|
+
|
|
21
|
+
### 手動匯入 Cookie
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
tronclass import-cookies "session=xxx; key=value; ..."
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
### 開始自動觀看
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
tronclass run
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
### 查看進度
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
tronclass status
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### 查看設定
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
tronclass config
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## 設定檔
|
|
46
|
+
|
|
47
|
+
設定檔位於 `~/.eclass-auto/config.json`:
|
|
48
|
+
|
|
49
|
+
```json
|
|
50
|
+
{
|
|
51
|
+
"headless": false,
|
|
52
|
+
"slowMo": 50,
|
|
53
|
+
"playbackRate": 2,
|
|
54
|
+
"courses": [
|
|
55
|
+
"https://eclass.yuntech.edu.tw/course/127331/content#/",
|
|
56
|
+
"https://eclass.yuntech.edu.tw/course/127343/content#/"
|
|
57
|
+
]
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## 功能
|
|
62
|
+
|
|
63
|
+
- 自動偵測已完成 / 未完成的活動
|
|
64
|
+
- 影片 2 倍速 + 靜音播放
|
|
65
|
+
- 已看完的自動跳過,不重複觀看
|
|
66
|
+
- 中斷後重跑自動接續進度(本地 progress.json)
|
|
67
|
+
- 跳過考試,只處理影片
|
|
68
|
+
- 自動登入取得 Cookie(`tronclass login`)
|
|
69
|
+
- Buffering 自動等待、暫停自動重播
|
|
70
|
+
|
|
71
|
+
## 注意事項
|
|
72
|
+
|
|
73
|
+
- Cookie 過期後需重新執行 `tronclass login`
|
|
74
|
+
- 自動化工具可能違反學校使用條款,請自行評估
|
|
75
|
+
- 建議先在測試課程驗證功能
|
package/bin/eclass.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const { program } = require('commander');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
const pkg = require('../package.json');
|
|
7
|
+
|
|
8
|
+
program
|
|
9
|
+
.name('tronclass')
|
|
10
|
+
.description('自動觀看 eclass/TronClass 影片、填寫表單')
|
|
11
|
+
.version(pkg.version);
|
|
12
|
+
|
|
13
|
+
program
|
|
14
|
+
.command('login')
|
|
15
|
+
.description('自動開啟瀏覽器登入,完成後自動取得 Cookie')
|
|
16
|
+
.option('--url <url>', '登入頁面網址', 'https://eclass.yuntech.edu.tw')
|
|
17
|
+
.action(async (opts) => {
|
|
18
|
+
const { autoLogin } = require('../src/auth');
|
|
19
|
+
await autoLogin(opts.url);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
program
|
|
23
|
+
.command('import-cookies <cookieString>')
|
|
24
|
+
.alias('ic')
|
|
25
|
+
.description('手動匯入 Cookie 字串')
|
|
26
|
+
.action(async (cookieString) => {
|
|
27
|
+
const { importCookies } = require('../src/auth');
|
|
28
|
+
importCookies(cookieString);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
program
|
|
32
|
+
.command('run')
|
|
33
|
+
.description('開始自動觀看影片')
|
|
34
|
+
.option('--headless', '無頭模式執行', false)
|
|
35
|
+
.option('--course <ids>', '指定課程 ID(逗號分隔)', '')
|
|
36
|
+
.action(async (opts) => {
|
|
37
|
+
const { run } = require('../src/index');
|
|
38
|
+
await run(opts);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
program
|
|
42
|
+
.command('status')
|
|
43
|
+
.description('查看各課程進度統計')
|
|
44
|
+
.action(async () => {
|
|
45
|
+
const { showStatus } = require('../src/index');
|
|
46
|
+
await showStatus();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
program
|
|
50
|
+
.command('config')
|
|
51
|
+
.description('顯示目前設定')
|
|
52
|
+
.action(() => {
|
|
53
|
+
const { showConfig } = require('../src/config');
|
|
54
|
+
showConfig();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
program.parse();
|
package/package.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "tronclass-auto",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "自動觀看 eclass/TronClass 影片、填寫表單的 CLI 工具",
|
|
5
|
+
"main": "src/index.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"tronclass": "./bin/eclass.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"start": "node bin/eclass.js run"
|
|
11
|
+
},
|
|
12
|
+
"keywords": ["eclass", "tronclass", "yuntech", "automation", "video"],
|
|
13
|
+
"author": "",
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"playwright": "^1.40.0",
|
|
17
|
+
"commander": "^12.0.0",
|
|
18
|
+
"chalk": "^4.1.2",
|
|
19
|
+
"open": "^8.4.2"
|
|
20
|
+
}
|
|
21
|
+
}
|
package/src/auth.js
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const crypto = require('crypto');
|
|
3
|
+
const chalk = require('chalk');
|
|
4
|
+
const { COOKIE_FILE, SALT_FILE, ensureDir, BASE_URL } = require('./config');
|
|
5
|
+
|
|
6
|
+
function getKey(password, salt) {
|
|
7
|
+
return crypto.pbkdf2Sync(password, salt, 480000, 32, 'sha256');
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function saveCookies(cookies, password = 'eclass-local-key') {
|
|
11
|
+
ensureDir();
|
|
12
|
+
let salt;
|
|
13
|
+
if (fs.existsSync(SALT_FILE)) {
|
|
14
|
+
salt = fs.readFileSync(SALT_FILE);
|
|
15
|
+
} else {
|
|
16
|
+
salt = crypto.randomBytes(16);
|
|
17
|
+
fs.writeFileSync(SALT_FILE, salt);
|
|
18
|
+
}
|
|
19
|
+
const key = getKey(password, salt);
|
|
20
|
+
const iv = crypto.randomBytes(16);
|
|
21
|
+
const cipher = crypto.createCipheriv('aes-256-cbc', key, iv);
|
|
22
|
+
const data = JSON.stringify(cookies);
|
|
23
|
+
let encrypted = cipher.update(data, 'utf-8');
|
|
24
|
+
encrypted = Buffer.concat([encrypted, cipher.final()]);
|
|
25
|
+
fs.writeFileSync(COOKIE_FILE, Buffer.concat([iv, encrypted]));
|
|
26
|
+
console.log(chalk.green(`[AUTH] Cookie 已加密儲存 (${cookies.length} 個)`));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function loadCookies(password = 'eclass-local-key') {
|
|
30
|
+
if (!fs.existsSync(COOKIE_FILE) || !fs.existsSync(SALT_FILE)) {
|
|
31
|
+
return [];
|
|
32
|
+
}
|
|
33
|
+
try {
|
|
34
|
+
const salt = fs.readFileSync(SALT_FILE);
|
|
35
|
+
const key = getKey(password, salt);
|
|
36
|
+
const raw = fs.readFileSync(COOKIE_FILE);
|
|
37
|
+
const iv = raw.subarray(0, 16);
|
|
38
|
+
const encrypted = raw.subarray(16);
|
|
39
|
+
const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv);
|
|
40
|
+
let decrypted = decipher.update(encrypted);
|
|
41
|
+
decrypted = Buffer.concat([decrypted, decipher.final()]);
|
|
42
|
+
const cookies = JSON.parse(decrypted.toString('utf-8'));
|
|
43
|
+
return cookies;
|
|
44
|
+
} catch (e) {
|
|
45
|
+
console.log(chalk.red(`[AUTH] Cookie 解密失敗: ${e.message}`));
|
|
46
|
+
return [];
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function parseCookieString(cookieStr) {
|
|
51
|
+
const cookies = [];
|
|
52
|
+
for (const part of cookieStr.split(';')) {
|
|
53
|
+
const trimmed = part.trim();
|
|
54
|
+
if (trimmed.includes('=')) {
|
|
55
|
+
const idx = trimmed.indexOf('=');
|
|
56
|
+
const name = trimmed.substring(0, idx).trim();
|
|
57
|
+
const value = trimmed.substring(idx + 1).trim();
|
|
58
|
+
cookies.push({
|
|
59
|
+
name,
|
|
60
|
+
value,
|
|
61
|
+
domain: '.yuntech.edu.tw',
|
|
62
|
+
path: '/'
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return cookies;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function importCookies(cookieStr) {
|
|
70
|
+
const cookies = parseCookieString(cookieStr);
|
|
71
|
+
if (cookies.length === 0) {
|
|
72
|
+
console.log(chalk.red('[AUTH] 找不到有效的 Cookie'));
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
saveCookies(cookies);
|
|
76
|
+
console.log(chalk.green(`[AUTH] 匯入 ${cookies.length} 個 Cookie`));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function autoLogin(baseUrl) {
|
|
80
|
+
console.log(chalk.cyan('\n=== 自動登入 ==='));
|
|
81
|
+
console.log(chalk.yellow('將開啟瀏覽器,請手動登入'));
|
|
82
|
+
console.log(chalk.yellow('登入成功後,Cookie 會自動儲存\n'));
|
|
83
|
+
|
|
84
|
+
const { chromium } = require('playwright');
|
|
85
|
+
|
|
86
|
+
const browser = await chromium.launch({ headless: false, slowMo: 50 });
|
|
87
|
+
const context = await browser.newContext();
|
|
88
|
+
const page = await context.newPage();
|
|
89
|
+
|
|
90
|
+
await page.goto(baseUrl, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
|
91
|
+
|
|
92
|
+
console.log(chalk.cyan('等待您完成登入...'));
|
|
93
|
+
console.log(chalk.cyan('(登入後頁面會自動跳轉,Cookie 會自動儲存)'));
|
|
94
|
+
|
|
95
|
+
let saved = false;
|
|
96
|
+
let checkCount = 0;
|
|
97
|
+
|
|
98
|
+
const checkInterval = setInterval(async () => {
|
|
99
|
+
checkCount++;
|
|
100
|
+
const url = page.url();
|
|
101
|
+
|
|
102
|
+
if (!url.includes('login') && !url.includes('auth') && !url.includes('cas')) {
|
|
103
|
+
if (!saved) {
|
|
104
|
+
saved = true;
|
|
105
|
+
const cookies = await context.cookies();
|
|
106
|
+
const eclassCookies = cookies.filter(c =>
|
|
107
|
+
c.domain.includes('yuntech.edu.tw') || c.domain.includes('eclass')
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
if (eclassCookies.length > 0) {
|
|
111
|
+
saveCookies(eclassCookies);
|
|
112
|
+
console.log(chalk.green(`\n[AUTH] 登入成功!自動取得 ${eclassCookies.length} 個 Cookie`));
|
|
113
|
+
console.log(chalk.green('[AUTH] 現在可以執行 eclass run 開始自動化\n'));
|
|
114
|
+
} else {
|
|
115
|
+
console.log(chalk.yellow('\n[AUTH] 找不到 eclass Cookie,請確認已成功登入'));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
clearInterval(checkInterval);
|
|
119
|
+
await browser.close();
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (checkCount > 300) {
|
|
124
|
+
console.log(chalk.red('\n[AUTH] 等待超時(5分鐘),請重新執行'));
|
|
125
|
+
clearInterval(checkInterval);
|
|
126
|
+
await browser.close();
|
|
127
|
+
}
|
|
128
|
+
}, 1000);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
module.exports = {
|
|
132
|
+
saveCookies, loadCookies, parseCookieString, importCookies, autoLogin
|
|
133
|
+
};
|
package/src/config.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const chalk = require('chalk');
|
|
4
|
+
|
|
5
|
+
const CONFIG_DIR = path.join(require('os').homedir(), '.eclass-auto');
|
|
6
|
+
const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
|
|
7
|
+
const PROGRESS_FILE = path.join(CONFIG_DIR, 'progress.json');
|
|
8
|
+
const COOKIE_FILE = path.join(CONFIG_DIR, 'cookies.enc');
|
|
9
|
+
const SALT_FILE = path.join(CONFIG_DIR, 'salt.bin');
|
|
10
|
+
|
|
11
|
+
const BASE_URL = 'https://eclass.yuntech.edu.tw';
|
|
12
|
+
|
|
13
|
+
const DEFAULT_CONFIG = {
|
|
14
|
+
headless: false,
|
|
15
|
+
slowMo: 50,
|
|
16
|
+
playbackRate: 2,
|
|
17
|
+
videoTimeout: 600,
|
|
18
|
+
courses: [],
|
|
19
|
+
formAnswers: {}
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
function ensureDir() {
|
|
23
|
+
if (!fs.existsSync(CONFIG_DIR)) {
|
|
24
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function loadConfig() {
|
|
29
|
+
ensureDir();
|
|
30
|
+
if (fs.existsSync(CONFIG_FILE)) {
|
|
31
|
+
const raw = fs.readFileSync(CONFIG_FILE, 'utf-8');
|
|
32
|
+
return { ...DEFAULT_CONFIG, ...JSON.parse(raw) };
|
|
33
|
+
}
|
|
34
|
+
return { ...DEFAULT_CONFIG };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function saveConfig(config) {
|
|
38
|
+
ensureDir();
|
|
39
|
+
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf-8');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function loadProgress() {
|
|
43
|
+
ensureDir();
|
|
44
|
+
if (fs.existsSync(PROGRESS_FILE)) {
|
|
45
|
+
return JSON.parse(fs.readFileSync(PROGRESS_FILE, 'utf-8'));
|
|
46
|
+
}
|
|
47
|
+
return {};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function saveProgress(data) {
|
|
51
|
+
ensureDir();
|
|
52
|
+
fs.writeFileSync(PROGRESS_FILE, JSON.stringify(data, null, 2), 'utf-8');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function markDone(courseId, activityId) {
|
|
56
|
+
const p = loadProgress();
|
|
57
|
+
p[`${courseId}_${activityId}`] = true;
|
|
58
|
+
saveProgress(p);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function isDone(courseId, activityId) {
|
|
62
|
+
const p = loadProgress();
|
|
63
|
+
return `${courseId}_${activityId}` in p;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function showConfig() {
|
|
67
|
+
const config = loadConfig();
|
|
68
|
+
console.log(chalk.cyan('\n=== 目前設定 ==='));
|
|
69
|
+
console.log(` headless: ${config.headless}`);
|
|
70
|
+
console.log(` playbackRate: ${config.playbackRate}x`);
|
|
71
|
+
console.log(` courses: ${config.courses.length} 個`);
|
|
72
|
+
config.courses.forEach((c, i) => console.log(` ${i + 1}. ${c}`));
|
|
73
|
+
console.log(` config: ${CONFIG_FILE}`);
|
|
74
|
+
console.log(` progress: ${PROGRESS_FILE}\n`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
module.exports = {
|
|
78
|
+
CONFIG_DIR, CONFIG_FILE, PROGRESS_FILE, COOKIE_FILE, SALT_FILE,
|
|
79
|
+
BASE_URL, DEFAULT_CONFIG,
|
|
80
|
+
ensureDir, loadConfig, saveConfig,
|
|
81
|
+
loadProgress, saveProgress, markDone, isDone, showConfig
|
|
82
|
+
};
|
package/src/index.js
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
const re = require;
|
|
2
|
+
const chalk = re('chalk');
|
|
3
|
+
const { loadCookies, autoLogin } = re('./auth');
|
|
4
|
+
const { watchVideo } = re('./video');
|
|
5
|
+
const { loadConfig, saveConfig, markDone, isDone, BASE_URL } = re('./config');
|
|
6
|
+
|
|
7
|
+
async function getUncompleted(page, courseId) {
|
|
8
|
+
try {
|
|
9
|
+
await page.goto(`${BASE_URL}/course/${courseId}/content#/`, {
|
|
10
|
+
waitUntil: 'domcontentloaded',
|
|
11
|
+
timeout: 15000
|
|
12
|
+
});
|
|
13
|
+
} catch (e) {}
|
|
14
|
+
await page.waitForTimeout(3000);
|
|
15
|
+
|
|
16
|
+
return page.evaluate(() => {
|
|
17
|
+
const el = document.querySelector('.learning-activities');
|
|
18
|
+
if (!el) return [];
|
|
19
|
+
const scope = angular.element(el).scope();
|
|
20
|
+
if (!scope) return [];
|
|
21
|
+
const activities = document.querySelectorAll('.learning-activity.sortable');
|
|
22
|
+
const result = [];
|
|
23
|
+
activities.forEach((actEl) => {
|
|
24
|
+
const actScope = angular.element(actEl).scope();
|
|
25
|
+
if (!actScope || !actScope.activity) return;
|
|
26
|
+
const a = actScope.activity;
|
|
27
|
+
let completeness = '';
|
|
28
|
+
try { completeness = scope.getActivityCompleteness(a); } catch (e) {}
|
|
29
|
+
if (completeness !== 'full') {
|
|
30
|
+
result.push({
|
|
31
|
+
id: a.id,
|
|
32
|
+
title: (a.title || '').substring(0, 60),
|
|
33
|
+
type: a.type || ''
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
return result;
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function processCourse(page, courseId) {
|
|
42
|
+
let done = 0;
|
|
43
|
+
let skip = 0;
|
|
44
|
+
|
|
45
|
+
const uncompleted = await getUncompleted(page, courseId);
|
|
46
|
+
const videos = uncompleted.filter(u => u.type === 'online_video');
|
|
47
|
+
|
|
48
|
+
const remaining = videos.filter(v => !isDone(courseId, v.id));
|
|
49
|
+
console.log(chalk.cyan(` Uncompleted videos: ${videos.length}, already done: ${videos.length - remaining.length}, remaining: ${remaining.length}`));
|
|
50
|
+
|
|
51
|
+
for (const act of remaining) {
|
|
52
|
+
console.log(chalk.white(`\n -> ${act.title}`));
|
|
53
|
+
const url = `${BASE_URL}/course/${courseId}/learning-activity/full-screen#/${act.id}`;
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 15000 });
|
|
57
|
+
} catch (e) {}
|
|
58
|
+
await page.waitForTimeout(5000);
|
|
59
|
+
|
|
60
|
+
if (!page.url().includes('learning-activity')) {
|
|
61
|
+
console.log(chalk.yellow(' [WARN] Failed to load activity page, skipping'));
|
|
62
|
+
skip++;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
const success = await watchVideo(page);
|
|
68
|
+
if (success) {
|
|
69
|
+
markDone(courseId, act.id);
|
|
70
|
+
done++;
|
|
71
|
+
console.log(chalk.green(' [OK] Saved to progress'));
|
|
72
|
+
} else {
|
|
73
|
+
skip++;
|
|
74
|
+
}
|
|
75
|
+
} catch (e) {
|
|
76
|
+
console.log(chalk.red(` [ERROR] ${e.message}`));
|
|
77
|
+
skip++;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
await page.waitForTimeout(2000);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
console.log(chalk.cyan(`\n Result: ${done} watched, ${skip} skipped`));
|
|
84
|
+
return { done, skip };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function showStatus() {
|
|
88
|
+
const config = loadConfig();
|
|
89
|
+
const cookies = loadCookies();
|
|
90
|
+
if (!cookies.length) {
|
|
91
|
+
console.log(chalk.red('[ERROR] 沒有 Cookie,請先執行 eclass login 或 eclass import-cookies'));
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const { chromium } = re('playwright');
|
|
96
|
+
const browser = await chromium.launch({ headless: true, slowMo: 50 });
|
|
97
|
+
const context = await browser.newContext();
|
|
98
|
+
await context.addCookies(cookies);
|
|
99
|
+
const page = await context.newPage();
|
|
100
|
+
|
|
101
|
+
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded', timeout: 15000 });
|
|
102
|
+
await page.waitForTimeout(2000);
|
|
103
|
+
|
|
104
|
+
if (page.url().includes('login')) {
|
|
105
|
+
console.log(chalk.red('[ERROR] Cookie 過期'));
|
|
106
|
+
await browser.close();
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const courses = config.courses || [];
|
|
111
|
+
console.log(chalk.cyan('\n=== 課程進度統計 ==='));
|
|
112
|
+
|
|
113
|
+
for (const courseUrl of courses) {
|
|
114
|
+
const match = courseUrl.match(/\/course\/(\d+)\//);
|
|
115
|
+
const courseId = match ? match[1] : '?';
|
|
116
|
+
try {
|
|
117
|
+
const uncompleted = await getUncompleted(page, courseId);
|
|
118
|
+
const total = uncompleted.length;
|
|
119
|
+
const videos = uncompleted.filter(u => u.type === 'online_video').length;
|
|
120
|
+
const exams = uncompleted.filter(u => u.type === 'exam').length;
|
|
121
|
+
const other = total - videos - exams;
|
|
122
|
+
console.log(chalk.white(`\n Course ${courseId}: ${total} uncompleted (${videos} videos, ${exams} exams, ${other} other)`));
|
|
123
|
+
} catch (e) {
|
|
124
|
+
console.log(chalk.red(`\n Course ${courseId}: ERROR - ${e.message}`));
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
console.log('');
|
|
129
|
+
await browser.close();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function run(opts = {}) {
|
|
133
|
+
const config = loadConfig();
|
|
134
|
+
const cookies = loadCookies();
|
|
135
|
+
|
|
136
|
+
if (!cookies.length) {
|
|
137
|
+
console.log(chalk.red('[ERROR] 沒有 Cookie'));
|
|
138
|
+
console.log(chalk.yellow(' 請執行: eclass login (自動取得 Cookie)'));
|
|
139
|
+
console.log(chalk.yellow(' 或: eclass import-cookies "session=xxx"'));
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (opts.headless) config.headless = true;
|
|
144
|
+
|
|
145
|
+
const { chromium } = re('playwright');
|
|
146
|
+
|
|
147
|
+
const browser = await chromium.launch({
|
|
148
|
+
headless: config.headless,
|
|
149
|
+
slowMo: config.slowMo || 50
|
|
150
|
+
});
|
|
151
|
+
const context = await browser.newContext({
|
|
152
|
+
viewport: { width: 1280, height: 900 },
|
|
153
|
+
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
|
|
154
|
+
});
|
|
155
|
+
await context.addCookies(cookies);
|
|
156
|
+
const page = await context.newPage();
|
|
157
|
+
|
|
158
|
+
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded', timeout: 15000 });
|
|
159
|
+
await page.waitForTimeout(2000);
|
|
160
|
+
|
|
161
|
+
if (page.url().includes('login')) {
|
|
162
|
+
console.log(chalk.red('[ERROR] Cookie 過期,請重新執行 eclass login'));
|
|
163
|
+
await browser.close();
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
console.log(chalk.green('[OK] Login verified'));
|
|
168
|
+
|
|
169
|
+
let courses = config.courses || [];
|
|
170
|
+
|
|
171
|
+
if (opts.course) {
|
|
172
|
+
const ids = opts.course.split(',').map(s => s.trim());
|
|
173
|
+
courses = courses.filter(url => ids.some(id => url.includes(id)));
|
|
174
|
+
if (courses.length === 0) {
|
|
175
|
+
courses = ids.map(id => `${BASE_URL}/course/${id}/content#/`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (!courses.length) {
|
|
180
|
+
console.log(chalk.yellow('[WARN] 沒有設定課程'));
|
|
181
|
+
console.log(chalk.yellow(' 請編輯 ~/.eclass-auto/config.json 加入 courses'));
|
|
182
|
+
await browser.close();
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
let totalDone = 0;
|
|
187
|
+
let totalSkip = 0;
|
|
188
|
+
|
|
189
|
+
for (let i = 0; i < courses.length; i++) {
|
|
190
|
+
const courseUrl = courses[i];
|
|
191
|
+
const match = courseUrl.match(/\/course\/(\d+)\//);
|
|
192
|
+
const courseId = match ? match[1] : '127331';
|
|
193
|
+
|
|
194
|
+
console.log(chalk.cyan(`\n${'='.repeat(50)}`));
|
|
195
|
+
console.log(chalk.cyan(`Course ${i + 1}/${courses.length} (ID: ${courseId})`));
|
|
196
|
+
console.log(chalk.cyan(`${'='.repeat(50)}`));
|
|
197
|
+
|
|
198
|
+
const result = await processCourse(page, courseId);
|
|
199
|
+
totalDone += result.done;
|
|
200
|
+
totalSkip += result.skip;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
console.log(chalk.cyan(`\n${'='.repeat(50)}`));
|
|
204
|
+
console.log(chalk.green(`ALL DONE: ${totalDone} watched, ${totalSkip} skipped`));
|
|
205
|
+
console.log(chalk.cyan(`${'='.repeat(50)}`));
|
|
206
|
+
|
|
207
|
+
await browser.close();
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
module.exports = { run, showStatus };
|
package/src/video.js
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
const chalk = require('chalk');
|
|
2
|
+
|
|
3
|
+
function log(msg) {
|
|
4
|
+
console.log(msg);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
async function watchVideo(page) {
|
|
8
|
+
const hasVideo = await page.evaluate(() => document.querySelector('video') !== null);
|
|
9
|
+
if (!hasVideo) {
|
|
10
|
+
log(chalk.yellow(' [VIDEO] No video element'));
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
log(chalk.blue(' [VIDEO] Muted + 2x speed'));
|
|
15
|
+
|
|
16
|
+
await page.evaluate(() => {
|
|
17
|
+
document.querySelectorAll('video').forEach(v => {
|
|
18
|
+
v.muted = true;
|
|
19
|
+
v.playbackRate = 2;
|
|
20
|
+
v.currentTime = 0;
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
await page.evaluate(() => document.querySelector('video').play());
|
|
25
|
+
await page.waitForTimeout(2000);
|
|
26
|
+
|
|
27
|
+
const isPlaying = await page.evaluate(() => {
|
|
28
|
+
const v = document.querySelector('video');
|
|
29
|
+
return v ? !v.paused : false;
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
if (!isPlaying) {
|
|
33
|
+
log(chalk.yellow(' [VIDEO] Clicking play...'));
|
|
34
|
+
await page.evaluate(() => {
|
|
35
|
+
document.querySelectorAll('[class*="play"], .vjs-big-play-button, button').forEach(btn => {
|
|
36
|
+
if (btn.offsetParent && (btn.className.includes('play') || btn.className.includes('Play'))) {
|
|
37
|
+
btn.click();
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
await page.waitForTimeout(2000);
|
|
42
|
+
await page.evaluate(() => document.querySelector('video').play());
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const duration = await page.evaluate(() => {
|
|
46
|
+
const v = document.querySelector('video');
|
|
47
|
+
return v ? v.duration : 0;
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
if (!duration || duration <= 0) {
|
|
51
|
+
log(chalk.yellow(' [VIDEO] Cannot get duration, waiting 30s...'));
|
|
52
|
+
await page.waitForTimeout(30000);
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const waitTime = (duration / 2) + 15;
|
|
57
|
+
log(chalk.blue(` [VIDEO] ${Math.floor(duration)}s video, waiting ~${Math.floor(waitTime)}s (2x)`));
|
|
58
|
+
|
|
59
|
+
let elapsed = 0;
|
|
60
|
+
let lastPct = -1;
|
|
61
|
+
let restartCount = 0;
|
|
62
|
+
|
|
63
|
+
while (elapsed < waitTime) {
|
|
64
|
+
await page.waitForTimeout(5000);
|
|
65
|
+
elapsed += 5;
|
|
66
|
+
|
|
67
|
+
const state = await page.evaluate(() => {
|
|
68
|
+
const v = document.querySelector('video');
|
|
69
|
+
if (!v || !v.duration) return { p: 100, e: true, paused: false, stalled: false };
|
|
70
|
+
return {
|
|
71
|
+
p: Math.floor((v.currentTime / v.duration) * 100),
|
|
72
|
+
e: v.ended || v.currentTime >= v.duration - 2,
|
|
73
|
+
paused: v.paused,
|
|
74
|
+
stalled: v.readyState < 2
|
|
75
|
+
};
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
if (state.p > lastPct) {
|
|
79
|
+
log(chalk.blue(` [VIDEO] ${state.p}% (${Math.floor(elapsed)}s)`));
|
|
80
|
+
lastPct = state.p;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (state.e) {
|
|
84
|
+
log(chalk.green(' [VIDEO] 100% Finished'));
|
|
85
|
+
break;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (state.stalled) {
|
|
89
|
+
if (elapsed % 15 === 0) {
|
|
90
|
+
log(chalk.gray(` [VIDEO] Buffering... (${Math.floor(elapsed)}s)`));
|
|
91
|
+
}
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (state.paused && elapsed > 5) {
|
|
96
|
+
restartCount++;
|
|
97
|
+
if (restartCount > 10) {
|
|
98
|
+
log(chalk.red(' [VIDEO] Too many restarts, giving up'));
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
log(chalk.yellow(` [VIDEO] Paused, restarting (#${restartCount})...`));
|
|
102
|
+
await page.evaluate(() => document.querySelector('video').play());
|
|
103
|
+
await page.waitForTimeout(1000);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
await page.waitForTimeout(3000);
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
module.exports = { watchVideo };
|