zhlgd-cli 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/CLAUDE.md +43 -0
- package/LICENSE +21 -0
- package/README.md +29 -0
- package/bin/index.js +23 -0
- package/package.json +29 -0
- package/src/commands/login.js +120 -0
- package/src/commands/logout.js +15 -0
- package/src/commands/msg.js +51 -0
- package/src/commands/news.js +27 -0
- package/src/commands/whoami.js +14 -0
- package/src/utils/api.js +40 -0
- package/src/utils/config.js +10 -0
- package/src/utils/display.js +139 -0
- package/src/utils/prompt.js +58 -0
- package/src/utils/rsa.js +20 -0
- package/src/utils/session.js +70 -0
package/CLAUDE.md
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# CLAUDE.md
|
|
2
|
+
|
|
3
|
+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
4
|
+
|
|
5
|
+
## Project Overview
|
|
6
|
+
|
|
7
|
+
zhlgd-cli is a Node.js CLI tool for 武汉理工大学 (Wuhan University of Technology) campus network management. It's published as the `zhlgd` command and currently supports campus network login via the `zhlgd login` command.
|
|
8
|
+
|
|
9
|
+
## Build & Run Commands
|
|
10
|
+
|
|
11
|
+
- **Install dependencies:** `npm install`
|
|
12
|
+
- **Run CLI locally:** `node bin/index.js <command>` (e.g., `node bin/index.js login -u <学号> -p <密码>`)
|
|
13
|
+
- **Link globally for development:** `npm link` then use `zhlgd` directly
|
|
14
|
+
- **Tests:** No test framework configured yet (`npm test` is a placeholder)
|
|
15
|
+
|
|
16
|
+
## Architecture
|
|
17
|
+
|
|
18
|
+
**Entry point:** `bin/index.js` — sets up a `commander` CLI program, registers all command modules, and parses args.
|
|
19
|
+
|
|
20
|
+
**Command pattern:** Each command lives in `src/commands/<name>.js` and exports a default function that receives the commander `program` instance and registers its subcommand via `program.command()`. New commands follow this pattern:
|
|
21
|
+
|
|
22
|
+
```js
|
|
23
|
+
// src/commands/example.js
|
|
24
|
+
export default (program) => {
|
|
25
|
+
program
|
|
26
|
+
.command('example')
|
|
27
|
+
.description('...')
|
|
28
|
+
.action(async (options) => { ... });
|
|
29
|
+
};
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Then register it in `bin/index.js`: `import exampleCommand from '../src/commands/example.js'; exampleCommand(program);`
|
|
33
|
+
|
|
34
|
+
**Shared utilities:** `src/utils/` — currently referenced but not yet created (e.g., `prompt.js` for interactive input). Place reusable logic here.
|
|
35
|
+
|
|
36
|
+
**Campus network API:** The login endpoint is `http://10.0.0.1/login` (POST with `{username, password}`). All network requests use `axios`.
|
|
37
|
+
|
|
38
|
+
## Key Conventions
|
|
39
|
+
|
|
40
|
+
- ESM modules throughout (`"type": "module"` in package.json) — use `import`/`export`, not `require()`
|
|
41
|
+
- CLI UI text is in Chinese (中文)
|
|
42
|
+
- User-facing output uses emoji prefixes (📶 🔐 ✅ ❌)
|
|
43
|
+
- Interactive prompts fall back gracefully: CLI flags (`-u`, `-p`) override interactive input
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kmoon
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# zhlgd-cli
|
|
2
|
+
|
|
3
|
+
武汉理工大学智慧理工大命令行工具。
|
|
4
|
+
|
|
5
|
+
## 安装
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install -g zhlgd
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## 命令
|
|
12
|
+
|
|
13
|
+
| 命令 | 说明 |
|
|
14
|
+
|------|------|
|
|
15
|
+
| `zhlgd login` | 校园网登录(`-u` 学号 `-p` 密码,省略则交互输入) |
|
|
16
|
+
| `zhlgd msg` | 查看我的消息 |
|
|
17
|
+
| `zhlgd news` | 查看校园新闻 |
|
|
18
|
+
| `zhlgd whoami` | 查看登录状态 |
|
|
19
|
+
| `zhlgd logout` | 退出登录 |
|
|
20
|
+
|
|
21
|
+
登录后 Cookie 自动保存至 `~/.zhlgd/cookies.json`,其他命令自动鉴权。
|
|
22
|
+
|
|
23
|
+
## 贡献
|
|
24
|
+
|
|
25
|
+
欢迎 Issue 和 PR。
|
|
26
|
+
|
|
27
|
+
## License
|
|
28
|
+
|
|
29
|
+
[MIT](LICENSE)
|
package/bin/index.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { Command } from 'commander';
|
|
4
|
+
import loginCommand from '../src/commands/login.js';
|
|
5
|
+
import logoutCommand from '../src/commands/logout.js';
|
|
6
|
+
import whoamiCommand from '../src/commands/whoami.js';
|
|
7
|
+
import msgCommand from '../src/commands/msg.js';
|
|
8
|
+
import newsCommand from '../src/commands/news.js';
|
|
9
|
+
|
|
10
|
+
const program = new Command();
|
|
11
|
+
|
|
12
|
+
program
|
|
13
|
+
.name('zhlgd')
|
|
14
|
+
.version('1.0.0', '-v, --version')
|
|
15
|
+
.description('智慧理工大 CLI 工具');
|
|
16
|
+
|
|
17
|
+
loginCommand(program);
|
|
18
|
+
logoutCommand(program);
|
|
19
|
+
whoamiCommand(program);
|
|
20
|
+
msgCommand(program);
|
|
21
|
+
newsCommand(program);
|
|
22
|
+
|
|
23
|
+
program.parse();
|
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "zhlgd-cli",
|
|
3
|
+
"bin": {
|
|
4
|
+
"zhlgd": "bin/index.js"
|
|
5
|
+
},
|
|
6
|
+
"version": "1.0.0",
|
|
7
|
+
"description": "武汉理工大学智慧理工大命令行工具",
|
|
8
|
+
"homepage": "https://github.com/kmoonn/zhlgd-cli#readme",
|
|
9
|
+
"bugs": {
|
|
10
|
+
"url": "https://github.com/kmoonn/zhlgd-cli/issues"
|
|
11
|
+
},
|
|
12
|
+
"repository": {
|
|
13
|
+
"type": "git",
|
|
14
|
+
"url": "git+https://github.com/kmoonn/zhlgd-cli.git"
|
|
15
|
+
},
|
|
16
|
+
"keywords": ["whut", "zhlgd", "cli", "武汉理工大学", "智慧理工大"],
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"author": "Kmoon",
|
|
19
|
+
"type": "module",
|
|
20
|
+
"main": "./bin/index.js",
|
|
21
|
+
"scripts": {
|
|
22
|
+
"test": "echo \"Error: no test specified\" && exit 1"
|
|
23
|
+
},
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"axios": "^1.17.0",
|
|
26
|
+
"commander": "^15.0.0",
|
|
27
|
+
"node-forge": "^1.4.0"
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import axios from 'axios';
|
|
2
|
+
import { prompt } from '../utils/prompt.js';
|
|
3
|
+
import { rsaEncrypt } from '../utils/rsa.js';
|
|
4
|
+
import { saveCookies, cookieHeader, collectCookies } from '../utils/session.js';
|
|
5
|
+
import { BASE_URL, UA } from '../utils/config.js';
|
|
6
|
+
|
|
7
|
+
const RSA_URL = `${BASE_URL}/tpass/rsa?skipWechat=true`;
|
|
8
|
+
const LOGIN_URL = `${BASE_URL}/tpass/login`;
|
|
9
|
+
const SERVICE = `${BASE_URL}/tp_up_new/`;
|
|
10
|
+
|
|
11
|
+
const UA_JSON = JSON.stringify({
|
|
12
|
+
ua: UA,
|
|
13
|
+
browser: { name: 'Chrome', version: '148.0.0.0', major: '148' },
|
|
14
|
+
cpu: {},
|
|
15
|
+
device: { model: 'Macintosh', vendor: 'Apple' },
|
|
16
|
+
engine: { name: 'Blink', version: '148.0.0.0' },
|
|
17
|
+
os: { name: 'macOS', version: '10.15.7' },
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
function extractHiddenFields(html) {
|
|
21
|
+
const lt = html.match(/name="lt" value="(.*?)"/)?.[1];
|
|
22
|
+
const execution = html.match(/name="execution" value="(.*?)"/)?.[1];
|
|
23
|
+
if (!lt || !execution) throw new Error('无法从登录页提取隐藏字段');
|
|
24
|
+
return { lt, execution };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function extractTicket(redirectUrl) {
|
|
28
|
+
const ticket = new URL(redirectUrl).searchParams.get('ticket');
|
|
29
|
+
if (!ticket) throw new Error('重定向 URL 中未找到 ticket');
|
|
30
|
+
return ticket;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function followRedirects(url, cookieJar, headers, maxHops = 10) {
|
|
34
|
+
let currentUrl = url;
|
|
35
|
+
for (let i = 0; i < maxHops; i++) {
|
|
36
|
+
const resp = await axios.get(currentUrl, {
|
|
37
|
+
headers: { ...headers, Cookie: cookieHeader(cookieJar) },
|
|
38
|
+
maxRedirects: 0,
|
|
39
|
+
validateStatus: () => true,
|
|
40
|
+
});
|
|
41
|
+
collectCookies(resp.headers['set-cookie'], cookieJar);
|
|
42
|
+
if (resp.status >= 300 && resp.status < 400 && resp.headers.location) {
|
|
43
|
+
currentUrl = new URL(resp.headers.location, currentUrl).href;
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
return resp;
|
|
47
|
+
}
|
|
48
|
+
throw new Error('重定向次数过多');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export default (program) => {
|
|
52
|
+
program
|
|
53
|
+
.command('login')
|
|
54
|
+
.description('校园网登录')
|
|
55
|
+
.option('-u, --username <account>', '学号')
|
|
56
|
+
.option('-p, --password <pwd>', '密码')
|
|
57
|
+
.action(async (options) => {
|
|
58
|
+
const username = options.username || await prompt('请输入学号:');
|
|
59
|
+
const password = options.password || await prompt('请输入密码:', true);
|
|
60
|
+
|
|
61
|
+
const cookieJar = {};
|
|
62
|
+
const commonHeaders = {
|
|
63
|
+
'User-Agent': UA,
|
|
64
|
+
'Referer': `${LOGIN_URL}?service=${SERVICE}`,
|
|
65
|
+
'Origin': BASE_URL,
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
// 1. 获取 RSA 公钥
|
|
70
|
+
const rsaResp = await axios.post(RSA_URL, null, {
|
|
71
|
+
headers: { ...commonHeaders, Cookie: cookieHeader(cookieJar) },
|
|
72
|
+
maxRedirects: 0, validateStatus: () => true,
|
|
73
|
+
});
|
|
74
|
+
collectCookies(rsaResp.headers['set-cookie'], cookieJar);
|
|
75
|
+
|
|
76
|
+
// 2. RSA 加密
|
|
77
|
+
const ul = rsaEncrypt(username, rsaResp.data.publicKey);
|
|
78
|
+
const pl = rsaEncrypt(password, rsaResp.data.publicKey);
|
|
79
|
+
|
|
80
|
+
// 3. 获取 lt、execution
|
|
81
|
+
const loginPageResp = await axios.get(`${LOGIN_URL}?service=${SERVICE}`, {
|
|
82
|
+
headers: { ...commonHeaders, Cookie: cookieHeader(cookieJar) },
|
|
83
|
+
maxRedirects: 0, validateStatus: () => true,
|
|
84
|
+
});
|
|
85
|
+
collectCookies(loginPageResp.headers['set-cookie'], cookieJar);
|
|
86
|
+
const { lt, execution } = extractHiddenFields(loginPageResp.data);
|
|
87
|
+
|
|
88
|
+
// 4. 提交登录
|
|
89
|
+
const loginResp = await axios.post(
|
|
90
|
+
LOGIN_URL,
|
|
91
|
+
new URLSearchParams({
|
|
92
|
+
ua: UA_JSON, visitorId: 'bc2f5efc890459a98f7cf9a753e6e6d1',
|
|
93
|
+
rsa: '', ul, pl, lt, execution, _eventId: 'submit',
|
|
94
|
+
}).toString(),
|
|
95
|
+
{
|
|
96
|
+
params: { service: SERVICE },
|
|
97
|
+
headers: { ...commonHeaders, Cookie: cookieHeader(cookieJar), 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
98
|
+
maxRedirects: 0, validateStatus: () => true,
|
|
99
|
+
},
|
|
100
|
+
);
|
|
101
|
+
collectCookies(loginResp.headers['set-cookie'], cookieJar);
|
|
102
|
+
|
|
103
|
+
if (loginResp.status !== 302) {
|
|
104
|
+
console.log('❌ 登录失败');
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// 5. 用 ST 换取完整 Cookie
|
|
109
|
+
const st = extractTicket(loginResp.headers.location);
|
|
110
|
+
await followRedirects(`${SERVICE}?ticket=${st}`, cookieJar, commonHeaders);
|
|
111
|
+
|
|
112
|
+
// 6. 保存
|
|
113
|
+
saveCookies(cookieJar);
|
|
114
|
+
console.log('✅ 登录成功');
|
|
115
|
+
|
|
116
|
+
} catch (err) {
|
|
117
|
+
console.log('❌ 登录失败:', err.message);
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { isLoggedIn, clearCookies } from '../utils/session.js';
|
|
2
|
+
|
|
3
|
+
export default (program) => {
|
|
4
|
+
program
|
|
5
|
+
.command('logout')
|
|
6
|
+
.description('退出登录')
|
|
7
|
+
.action(() => {
|
|
8
|
+
if (!isLoggedIn()) {
|
|
9
|
+
console.log('⚠️ 当前未登录');
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
clearCookies();
|
|
13
|
+
console.log('✅ 已退出登录');
|
|
14
|
+
});
|
|
15
|
+
};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { apiRequest, formatDateTime } from '../utils/api.js';
|
|
2
|
+
import { BASE_URL } from '../utils/config.js';
|
|
3
|
+
|
|
4
|
+
const C = {
|
|
5
|
+
reset: '\x1b[0m',
|
|
6
|
+
bold: '\x1b[1m',
|
|
7
|
+
dim: '\x1b[2m',
|
|
8
|
+
cyan: '\x1b[36m',
|
|
9
|
+
yellow: '\x1b[33m',
|
|
10
|
+
gray: '\x1b[90m',
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export default (program) => {
|
|
14
|
+
program
|
|
15
|
+
.command('msg')
|
|
16
|
+
.description('查看我的消息')
|
|
17
|
+
.action(async () => {
|
|
18
|
+
try {
|
|
19
|
+
const data = await apiRequest(
|
|
20
|
+
`${BASE_URL}/tp_up_new/up/newhome/getFpMsgList`,
|
|
21
|
+
{ MSG_TYPE: '3' },
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
if (!data?.length) {
|
|
25
|
+
console.log(`${C.dim} 暂无消息${C.reset}`);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const padLen = String(data.length).length;
|
|
30
|
+
|
|
31
|
+
data.forEach((item, i) => {
|
|
32
|
+
if (i > 0) console.log();
|
|
33
|
+
|
|
34
|
+
const idx = String(i + 1).padStart(padLen);
|
|
35
|
+
const indent = ' '.repeat(padLen + 2);
|
|
36
|
+
|
|
37
|
+
// 序号 + 内容
|
|
38
|
+
console.log(` ${C.cyan}${C.bold}${idx}.${C.reset} ${C.bold}${item.content.replace(/\n/g, ' ')}${C.reset}`);
|
|
39
|
+
|
|
40
|
+
// 来源 · 时间
|
|
41
|
+
console.log(`${indent}${C.yellow}${item.sysName}${C.reset} ${C.gray}·${C.reset} ${C.dim}${formatDateTime(item.sendTime)}${C.reset}`);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
console.log();
|
|
45
|
+
console.log(`${C.dim} 共 ${data.length} 条${C.reset}`);
|
|
46
|
+
|
|
47
|
+
} catch (err) {
|
|
48
|
+
console.log('❌', err.response?.status === 401 ? '登录已过期,请重新执行 zhlgd login' : err.message);
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { apiRequest, formatDate } from '../utils/api.js';
|
|
2
|
+
import { BASE_URL } from '../utils/config.js';
|
|
3
|
+
import { renderNewsList } from '../utils/display.js';
|
|
4
|
+
|
|
5
|
+
export default (program) => {
|
|
6
|
+
program
|
|
7
|
+
.command('news')
|
|
8
|
+
.description('查看校园新闻')
|
|
9
|
+
.action(async () => {
|
|
10
|
+
try {
|
|
11
|
+
const data = await apiRequest(
|
|
12
|
+
`${BASE_URL}/tp_up_new/up/newhome/getNewsNotice`,
|
|
13
|
+
{ channel: '13962' },
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
renderNewsList(data, (item) => ({
|
|
17
|
+
title: item.PIM_TITLE,
|
|
18
|
+
tag: item.CHNLDESC,
|
|
19
|
+
time: formatDate(item.OPT_DATE),
|
|
20
|
+
url: item.URL,
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
} catch (err) {
|
|
24
|
+
console.log('❌', err.response?.status === 401 ? '登录已过期,请重新执行 zhlgd login' : err.message);
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { isLoggedIn } from '../utils/session.js';
|
|
2
|
+
|
|
3
|
+
export default (program) => {
|
|
4
|
+
program
|
|
5
|
+
.command('whoami')
|
|
6
|
+
.description('查看登录状态')
|
|
7
|
+
.action(() => {
|
|
8
|
+
if (!isLoggedIn()) {
|
|
9
|
+
console.log('⚠️ 未登录,请先执行 zhlgd login');
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
console.log('✅ 已登录');
|
|
13
|
+
});
|
|
14
|
+
};
|
package/src/utils/api.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import axios from 'axios';
|
|
2
|
+
import { isLoggedIn, loadCookies, cookieHeader } from './session.js';
|
|
3
|
+
import { UA, API_HEADERS } from './config.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 带鉴权的 API 请求,未登录时自动提示
|
|
7
|
+
*/
|
|
8
|
+
export async function apiRequest(url, body) {
|
|
9
|
+
if (!isLoggedIn()) {
|
|
10
|
+
console.log('⚠️ 未登录,请先执行 zhlgd login');
|
|
11
|
+
process.exit(1);
|
|
12
|
+
}
|
|
13
|
+
const cookies = loadCookies();
|
|
14
|
+
const resp = await axios.post(url, body, {
|
|
15
|
+
headers: {
|
|
16
|
+
...API_HEADERS,
|
|
17
|
+
'cookie': cookieHeader(cookies),
|
|
18
|
+
'User-Agent': UA,
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
return resp.data;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 格式化时间戳为日期字符串
|
|
26
|
+
*/
|
|
27
|
+
export function formatDate(ts) {
|
|
28
|
+
const d = new Date(ts);
|
|
29
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
30
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 格式化时间戳为日期时间字符串
|
|
35
|
+
*/
|
|
36
|
+
export function formatDateTime(ts) {
|
|
37
|
+
const d = new Date(ts);
|
|
38
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
39
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
40
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export const BASE_URL = 'https://zhlgd.whut.edu.cn';
|
|
2
|
+
|
|
3
|
+
export const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36';
|
|
4
|
+
|
|
5
|
+
export const API_HEADERS = {
|
|
6
|
+
'accept': 'application/json, text/javascript, */*; q=0.01',
|
|
7
|
+
'content-type': 'application/json;charset=UTF-8',
|
|
8
|
+
'x-requested-with': 'XMLHttpRequest',
|
|
9
|
+
'Referer': 'https://zhlgd.whut.edu.cn/tp_up_new/view?m=up',
|
|
10
|
+
};
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 终端美化输出工具
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import process from 'process';
|
|
6
|
+
|
|
7
|
+
// ANSI 颜色
|
|
8
|
+
const C = {
|
|
9
|
+
reset: '\x1b[0m',
|
|
10
|
+
bold: '\x1b[1m',
|
|
11
|
+
dim: '\x1b[2m',
|
|
12
|
+
cyan: '\x1b[36m',
|
|
13
|
+
yellow: '\x1b[33m',
|
|
14
|
+
blue: '\x1b[34m',
|
|
15
|
+
gray: '\x1b[90m',
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* 去除 ANSI 转义序列
|
|
20
|
+
*/
|
|
21
|
+
function stripAnsi(str) {
|
|
22
|
+
return str.replace(/\x1b\[[0-9;]*m/g, '');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 计算字符串的显示宽度(忽略 ANSI,中文占2列)
|
|
27
|
+
*/
|
|
28
|
+
function displayWidth(str) {
|
|
29
|
+
let w = 0;
|
|
30
|
+
for (const ch of stripAnsi(str)) w += ch.charCodeAt(0) > 127 ? 2 : 1;
|
|
31
|
+
return w;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 获取内容区宽度(终端宽度 - 边框和缩进)
|
|
36
|
+
*/
|
|
37
|
+
function contentWidth() {
|
|
38
|
+
return (process.stdout.columns || 80) - 6;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 文本按 maxWidth 折行(中英混排,URL 不折行)
|
|
43
|
+
*/
|
|
44
|
+
export function wrapText(text, maxWidth) {
|
|
45
|
+
const lines = [];
|
|
46
|
+
for (const paragraph of text.split('\n')) {
|
|
47
|
+
const parts = paragraph.split(/(https?:\/\/\S+)/g);
|
|
48
|
+
let line = '';
|
|
49
|
+
let width = 0;
|
|
50
|
+
|
|
51
|
+
for (const part of parts) {
|
|
52
|
+
if (/^https?:\/\//.test(part)) {
|
|
53
|
+
if (width > 0) { lines.push(line); line = ''; width = 0; }
|
|
54
|
+
lines.push(part);
|
|
55
|
+
} else {
|
|
56
|
+
for (const char of part) {
|
|
57
|
+
width += char.charCodeAt(0) > 127 ? 2 : 1;
|
|
58
|
+
line += char;
|
|
59
|
+
if (width >= maxWidth) { lines.push(line); line = ''; width = 0; }
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (line) lines.push(line);
|
|
64
|
+
}
|
|
65
|
+
return lines;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* 输出消息列表(带边框卡片)
|
|
70
|
+
*/
|
|
71
|
+
export function renderList(items, mapper) {
|
|
72
|
+
if (!items?.length) {
|
|
73
|
+
console.log(`${C.dim} 暂无内容${C.reset}`);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const cw = contentWidth();
|
|
78
|
+
const hLine = '─'.repeat(cw);
|
|
79
|
+
|
|
80
|
+
items.forEach((item, i) => {
|
|
81
|
+
const { title, time, body } = mapper(item);
|
|
82
|
+
if (i > 0) console.log();
|
|
83
|
+
|
|
84
|
+
// 边框
|
|
85
|
+
console.log(` ${C.dim}┌${hLine}┐${C.reset}`);
|
|
86
|
+
|
|
87
|
+
// 标题行
|
|
88
|
+
const titleRaw = ` ${title} ${time}`;
|
|
89
|
+
const titleStyled = ` ${C.cyan}${C.bold}${title}${C.reset} ${C.gray}${time}${C.reset}`;
|
|
90
|
+
const pad = ' '.repeat(Math.max(0, cw - displayWidth(titleRaw)));
|
|
91
|
+
console.log(` ${C.dim}│${C.reset}${titleStyled}${pad}${C.dim}│${C.reset}`);
|
|
92
|
+
|
|
93
|
+
// 分隔线
|
|
94
|
+
console.log(` ${C.dim}├${hLine}┤${C.reset}`);
|
|
95
|
+
|
|
96
|
+
// 内容
|
|
97
|
+
const wrapped = wrapText(body, cw - 2);
|
|
98
|
+
for (const line of wrapped) {
|
|
99
|
+
const dw = displayWidth(line);
|
|
100
|
+
if (dw <= cw - 2) {
|
|
101
|
+
console.log(` ${C.dim}│${C.reset} ${line}${' '.repeat(cw - 2 - dw)} ${C.dim}│${C.reset}`);
|
|
102
|
+
} else {
|
|
103
|
+
console.log(` ${C.dim}│${C.reset} ${line}`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
console.log(` ${C.dim}└${hLine}┘${C.reset}`);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
console.log();
|
|
111
|
+
console.log(`${C.dim} 共 ${items.length} 条${C.reset}`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* 输出新闻列表
|
|
116
|
+
*/
|
|
117
|
+
export function renderNewsList(items, mapper) {
|
|
118
|
+
if (!items?.length) {
|
|
119
|
+
console.log(`${C.dim} 暂无内容${C.reset}`);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const padLen = String(items.length).length;
|
|
124
|
+
|
|
125
|
+
items.forEach((item, i) => {
|
|
126
|
+
const { title, tag, time, url } = mapper(item);
|
|
127
|
+
if (i > 0) console.log();
|
|
128
|
+
|
|
129
|
+
const idx = String(i + 1).padStart(padLen);
|
|
130
|
+
const indent = ' '.repeat(padLen + 2);
|
|
131
|
+
|
|
132
|
+
console.log(` ${C.cyan}${C.bold}${idx}.${C.reset} ${C.bold}${title}${C.reset}`);
|
|
133
|
+
console.log(`${indent}${C.yellow}${tag}${C.reset} ${C.gray}·${C.reset} ${C.dim}${time}${C.reset}`);
|
|
134
|
+
console.log(`${indent}${C.blue}${C.dim}${url}${C.reset}`);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
console.log();
|
|
138
|
+
console.log(`${C.dim} 共 ${items.length} 条${C.reset}`);
|
|
139
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import * as readline from 'readline';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 交互式命令行输入
|
|
5
|
+
* @param {string} question - 提示文字
|
|
6
|
+
* @param {boolean} [hidden=false] - 是否隐藏输入(用于密码)
|
|
7
|
+
* @returns {Promise<string>}
|
|
8
|
+
*/
|
|
9
|
+
export function prompt(question, hidden = false) {
|
|
10
|
+
// hidden 且在 TTY 终端下:用 rawMode 逐字符读取,不回显
|
|
11
|
+
if (hidden && process.stdin.isTTY) {
|
|
12
|
+
return new Promise((resolve) => {
|
|
13
|
+
process.stdout.write(question);
|
|
14
|
+
process.stdin.setRawMode(true);
|
|
15
|
+
process.stdin.resume();
|
|
16
|
+
process.stdin.setEncoding('utf8');
|
|
17
|
+
|
|
18
|
+
let input = '';
|
|
19
|
+
const onData = (char) => {
|
|
20
|
+
switch (char) {
|
|
21
|
+
case '\n':
|
|
22
|
+
case '\r':
|
|
23
|
+
case '': // Ctrl+D
|
|
24
|
+
process.stdin.setRawMode(false);
|
|
25
|
+
process.stdin.pause();
|
|
26
|
+
process.stdin.removeListener('data', onData);
|
|
27
|
+
process.stdout.write('\n');
|
|
28
|
+
resolve(input);
|
|
29
|
+
break;
|
|
30
|
+
case '': // Ctrl+C
|
|
31
|
+
process.stdout.write('\n');
|
|
32
|
+
process.exit(1);
|
|
33
|
+
break;
|
|
34
|
+
case '': // 退格
|
|
35
|
+
case '\b':
|
|
36
|
+
input = input.slice(0, -1);
|
|
37
|
+
break;
|
|
38
|
+
default:
|
|
39
|
+
input += char;
|
|
40
|
+
break;
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
process.stdin.on('data', onData);
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// 普通输入 或 非TTY管道输入:使用 readline
|
|
48
|
+
const rl = readline.createInterface({
|
|
49
|
+
input: process.stdin,
|
|
50
|
+
output: process.stdout,
|
|
51
|
+
});
|
|
52
|
+
return new Promise((resolve) => {
|
|
53
|
+
rl.question(question, (answer) => {
|
|
54
|
+
rl.close();
|
|
55
|
+
resolve(answer);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
}
|
package/src/utils/rsa.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import forge from 'node-forge';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* RSA PKCS1 v1.5 加密(与 Python Crypto.Cipher.PKCS1_v1_5 对齐)
|
|
5
|
+
* @param {string} plaintext - 明文
|
|
6
|
+
* @param {string} publicKeyB64 - Base64 编码的 RSA 公钥(不含头尾)
|
|
7
|
+
* @returns {string} Base64 编码的密文
|
|
8
|
+
*/
|
|
9
|
+
export function rsaEncrypt(plaintext, publicKeyB64) {
|
|
10
|
+
const pubKeyPem = [
|
|
11
|
+
'-----BEGIN PUBLIC KEY-----',
|
|
12
|
+
publicKeyB64,
|
|
13
|
+
'-----END PUBLIC KEY-----',
|
|
14
|
+
].join('\n');
|
|
15
|
+
|
|
16
|
+
const publicKey = forge.pki.publicKeyFromPem(pubKeyPem);
|
|
17
|
+
|
|
18
|
+
const encrypted = publicKey.encrypt(plaintext, 'RSAES-PKCS1-V1_5');
|
|
19
|
+
return forge.util.encode64(encrypted);
|
|
20
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import os from 'os';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
|
|
5
|
+
const CONFIG_DIR = path.join(os.homedir(), '.zhlgd');
|
|
6
|
+
const COOKIE_FILE = path.join(CONFIG_DIR, 'cookies.json');
|
|
7
|
+
|
|
8
|
+
function ensureDir() {
|
|
9
|
+
if (!fs.existsSync(CONFIG_DIR)) {
|
|
10
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* 保存 Cookie 到本地文件
|
|
16
|
+
*/
|
|
17
|
+
export function saveCookies(cookieJar) {
|
|
18
|
+
ensureDir();
|
|
19
|
+
fs.writeFileSync(COOKIE_FILE, JSON.stringify(cookieJar, null, 2), 'utf-8');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 从本地文件读取 Cookie
|
|
24
|
+
*/
|
|
25
|
+
export function loadCookies() {
|
|
26
|
+
try {
|
|
27
|
+
if (!fs.existsSync(COOKIE_FILE)) return {};
|
|
28
|
+
return JSON.parse(fs.readFileSync(COOKIE_FILE, 'utf-8'));
|
|
29
|
+
} catch {
|
|
30
|
+
return {};
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 清除本地 Cookie
|
|
36
|
+
*/
|
|
37
|
+
export function clearCookies() {
|
|
38
|
+
try {
|
|
39
|
+
if (fs.existsSync(COOKIE_FILE)) fs.unlinkSync(COOKIE_FILE);
|
|
40
|
+
} catch { /* ignore */ }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 是否已登录
|
|
45
|
+
*/
|
|
46
|
+
export function isLoggedIn() {
|
|
47
|
+
return Object.keys(loadCookies()).length > 0;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 将 cookie jar 拼成 Cookie 请求头
|
|
52
|
+
*/
|
|
53
|
+
export function cookieHeader(cookieJar) {
|
|
54
|
+
return Object.entries(cookieJar).map(([k, v]) => `${k}=${v}`).join('; ');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* 从响应的 Set-Cookie 头收集 cookie 并更新 jar
|
|
59
|
+
*/
|
|
60
|
+
export function collectCookies(setCookieHeaders, cookieJar) {
|
|
61
|
+
if (!setCookieHeaders) return;
|
|
62
|
+
const headers = Array.isArray(setCookieHeaders) ? setCookieHeaders : [setCookieHeaders];
|
|
63
|
+
for (const raw of headers) {
|
|
64
|
+
const [kv] = raw.split(';');
|
|
65
|
+
const eqIdx = kv.indexOf('=');
|
|
66
|
+
if (eqIdx > 0) {
|
|
67
|
+
cookieJar[kv.substring(0, eqIdx).trim()] = kv.substring(eqIdx + 1).trim();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|