linke-cli 0.1.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/LICENSE +21 -0
- package/README.md +62 -0
- package/bin/linke.mjs +16 -0
- package/package.json +37 -0
- package/skills/linke/SKILL.md +75 -0
- package/src/bin.js +217 -0
- package/src/cloudOcr.js +54 -0
- package/src/config.js +93 -0
- package/src/errors.js +69 -0
- package/src/prompt.js +39 -0
- package/src/schools/registry.js +38 -0
- package/src/schools/sdufe/adapter.js +220 -0
- package/src/schools/sdufe/encoding.js +24 -0
- package/src/schools/sdufe/parsers.js +165 -0
- package/src/session.js +139 -0
- package/src/skill.js +55 -0
- package/src/util.js +48 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Linke App contributors
|
|
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,62 @@
|
|
|
1
|
+
# linke-cli
|
|
2
|
+
|
|
3
|
+
林课教务 CLI:把山财教务(正方 jsxsd)的只读查询能力封装为本机命令行
|
|
4
|
+
工具,供人类与 AI agent(Claude Code / ZCode 等)通过 shell 调用。
|
|
5
|
+
|
|
6
|
+
架构对标 lark-cli:**CLI 承载全部复杂度,skill 保持极薄**。随包分发一份
|
|
7
|
+
skill 说明书(`skills/linke/SKILL.md`),一条命令装入 agent 的 skills 目录。
|
|
8
|
+
|
|
9
|
+
## 安装与初始化
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm install -g linke-cli # 或 npx linke-cli <命令>
|
|
13
|
+
linke config # 交互式录入学号/密码(输入不回显)
|
|
14
|
+
linke schedule # 直接用,登录全自动
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
要求 Node.js ≥ 18.14,零运行时依赖。
|
|
18
|
+
|
|
19
|
+
## 命令
|
|
20
|
+
|
|
21
|
+
| 命令 | 说明 |
|
|
22
|
+
|---|---|
|
|
23
|
+
| `linke config [--clear]` | 录入/清除教务凭据(存 `~/.linke-cli/config.json`,权限 600) |
|
|
24
|
+
| `linke status` | 配置与会话状态(JSON) |
|
|
25
|
+
| `linke login` | 强制重登(平时自动,排障用) |
|
|
26
|
+
| `linke scores [--term 2025-2026-1]` | 成绩(缺省全部学期,JSON) |
|
|
27
|
+
| `linke schedule [--term ...] [--week 3]` | 课表(缺省当前学期全部周,JSON) |
|
|
28
|
+
| `linke schools` | 列出学校适配器 |
|
|
29
|
+
| `linke skill install [--path DIR]` | 安装 skill 说明书到 agent skills 目录 |
|
|
30
|
+
| `linke logout` | 清除本机 session(保留凭据) |
|
|
31
|
+
|
|
32
|
+
stdout 只输出业务 JSON;进度与错误走 stderr。exit code 契约见
|
|
33
|
+
`src/errors.js` 与 `skills/linke/SKILL.md`。
|
|
34
|
+
|
|
35
|
+
## 安全设计
|
|
36
|
+
|
|
37
|
+
- **凭据不出现在命令行参数、shell 历史与任何日志**:只经 `linke config`
|
|
38
|
+
交互式录入(密码不回显),落盘 `~/.linke-cli/config.json`(0600,目录 0700)。
|
|
39
|
+
- **登录请求从用户本机直发教务系统**,不经云服务器代理转发。
|
|
40
|
+
- **云端只做无状态验证码识别**(传图返文字,`App.Captcha.Recognize`),
|
|
41
|
+
学号/密码/cookie 不离开本机。
|
|
42
|
+
- **鉴权状态机内建于每个命令**:session 有效直接用、过期自动重登、
|
|
43
|
+
凭据失效才报错(exit 2)提示重新 `linke config`。
|
|
44
|
+
- **一期只读**:不提供选课/退课/评教等有副作用的接口。
|
|
45
|
+
|
|
46
|
+
## 学校适配器分层
|
|
47
|
+
|
|
48
|
+
核心命令框架(`src/bin.js`、`src/session.js`)不含任何学校特有逻辑;
|
|
49
|
+
山财全部逻辑收在 `src/schools/sdufe/`(登录流程、页面解析、密码加密),
|
|
50
|
+
经 `src/schools/registry.js` 暴露统一接口。新增学校 = 新增
|
|
51
|
+
`src/schools/<id>/` 适配器目录并注册。
|
|
52
|
+
|
|
53
|
+
## 开发
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
git clone <repo> && cd linke-cli
|
|
57
|
+
npm link # 本地注册 linke 命令
|
|
58
|
+
npm test # 解析器/加密对拍与行为测试
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
解析器正则与现役实现同源(`linke_PHP` Model 层 + uni-app 端本机直连
|
|
62
|
+
实现),教务页面改版时以两处现役代码为准回灌。
|
package/bin/linke.mjs
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* linke-cli 入口。参数解析与命令分发,业务逻辑在 src/。
|
|
4
|
+
*/
|
|
5
|
+
import { runCli } from '../src/bin.js'
|
|
6
|
+
|
|
7
|
+
runCli(process.argv.slice(2)).then(
|
|
8
|
+
(code) => {
|
|
9
|
+
process.exitCode = code ?? 0
|
|
10
|
+
},
|
|
11
|
+
(err) => {
|
|
12
|
+
// runCli 内部已负责分类报错;这里是最后的兜底,避免裸栈打到 stdout
|
|
13
|
+
console.error(`[linke] 未预期错误:${err && err.message ? err.message : err}`)
|
|
14
|
+
process.exitCode = 1
|
|
15
|
+
}
|
|
16
|
+
)
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "linke-cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "林课 CLI:把山财教务能力(课表/成绩只读查询)封装为本机命令行工具,供用户 agent 通过 shell 调用。登录请求从本机直发教务系统,验证码识别调用林课云端无状态 OCR 接口。",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"linke": "./bin/linke.mjs"
|
|
8
|
+
},
|
|
9
|
+
"engines": {
|
|
10
|
+
"node": ">=18.14"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"bin/",
|
|
14
|
+
"src/",
|
|
15
|
+
"skills/",
|
|
16
|
+
"README.md",
|
|
17
|
+
"LICENSE"
|
|
18
|
+
],
|
|
19
|
+
"scripts": {
|
|
20
|
+
"test": "node --test test/*.test.mjs"
|
|
21
|
+
},
|
|
22
|
+
"keywords": [
|
|
23
|
+
"sdufe",
|
|
24
|
+
"jw",
|
|
25
|
+
"cli",
|
|
26
|
+
"agent-skill"
|
|
27
|
+
],
|
|
28
|
+
"license": "MIT",
|
|
29
|
+
"repository": {
|
|
30
|
+
"type": "git",
|
|
31
|
+
"url": "git+https://github.com/ChuHanModel/linke-cli.git"
|
|
32
|
+
},
|
|
33
|
+
"homepage": "https://github.com/ChuHanModel/linke-cli#readme",
|
|
34
|
+
"bugs": {
|
|
35
|
+
"url": "https://github.com/ChuHanModel/linke-cli/issues"
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: linke
|
|
3
|
+
version: 0.1.0
|
|
4
|
+
description: "林课教务查询 CLI(山东财经大学正方教务):查课表、查成绩、看教务登录状态。当用户问「我这学期课表」「今天有什么课」「我的成绩/绩点」「挂了什么课」「教务登录状态」等教务只读信息时使用。登录全自动(验证码云端识别),无需用户介入。"
|
|
5
|
+
metadata:
|
|
6
|
+
requires:
|
|
7
|
+
bins: ["linke"]
|
|
8
|
+
cliHelp: "linke help"
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
# 林课教务查询(linke-cli skill)
|
|
12
|
+
|
|
13
|
+
linke 是装在用户本机的教务只读查询工具(山东财经大学,正方 jsxsd 系统)。
|
|
14
|
+
复杂度全在 CLI 内;本 skill 只教你如何在 shell 里调用它。
|
|
15
|
+
|
|
16
|
+
## 前置检查
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
linke status
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
- `configured: false` → 用户还没配置凭据。**引导用户本人**在终端运行
|
|
23
|
+
`linke config`(交互式录入学号密码,输入不回显)。**你不代输密码、
|
|
24
|
+
不把密码写进任何命令**——凭据只经用户本人的终端进入 `~/.linke-cli/`。
|
|
25
|
+
- `configured: true` 即可用,无需关心登录:每个命令内置鉴权状态机,
|
|
26
|
+
session 过期自动重登,只有凭据失效才需要人介入。
|
|
27
|
+
|
|
28
|
+
## 命令
|
|
29
|
+
|
|
30
|
+
| 需求 | 命令 |
|
|
31
|
+
|---|---|
|
|
32
|
+
| 本学期课表(全部周) | `linke schedule` |
|
|
33
|
+
| 指定学期课表 | `linke schedule --term 2025-2026-1` |
|
|
34
|
+
| 单周课表 | `linke schedule --week 3` |
|
|
35
|
+
| 全部成绩 | `linke scores` |
|
|
36
|
+
| 指定学期成绩 | `linke scores --term 2025-2026-1` |
|
|
37
|
+
| 登录/配置状态 | `linke status` |
|
|
38
|
+
| 强制重登(排障用) | `linke login` |
|
|
39
|
+
|
|
40
|
+
## 输出契约
|
|
41
|
+
|
|
42
|
+
- **stdout 只有 JSON**(进度与提示都在 stderr),直接解析 stdout 即可。
|
|
43
|
+
- 课表:`{ term, week, weeks: [[7 个格子] × 节次], remark? }`;每格
|
|
44
|
+
`{ course, teacher, time, location }`,空格子四字段全空串。`weeks` 外层
|
|
45
|
+
下标 = 节次-1,内层下标 = 星期-1(周一=0)。
|
|
46
|
+
- 成绩:行数组 `[{ term, courseCode, courseName, scoreText, score, nature }]`;
|
|
47
|
+
数值成绩 `score` 为 0-100 整数,等级制(优/良/合格等)`score` 为 null、
|
|
48
|
+
看 `scoreText`。`courseName` 取自成绩行内紧邻成绩的列,个别页面列序
|
|
49
|
+
不同时可能为空,以 `courseCode` 为准。
|
|
50
|
+
|
|
51
|
+
## 报错自救(exit code)
|
|
52
|
+
|
|
53
|
+
| exit code | 含义 | 你的动作 |
|
|
54
|
+
|---|---|---|
|
|
55
|
+
| 0 | 成功 | 解析 stdout |
|
|
56
|
+
| 2 | 凭据失效(教务报密码错误) | 引导用户本人重新运行 `linke config`,不要自行重试 |
|
|
57
|
+
| 3 | 验证码自动识别连续未通过 | 直接重试一次同一命令;仍失败告知用户稍后再试 |
|
|
58
|
+
| 4 | 网络不可达(教务或云端识别) | 告知用户检查网络后重试 |
|
|
59
|
+
| 5 | 解析失败(教务页面改版) | 告知用户到 linke-cli 仓库提 issue,不要反复重试 |
|
|
60
|
+
| 6 | 未配置凭据 | 引导用户本人运行 `linke config` |
|
|
61
|
+
|
|
62
|
+
stderr 末行「提示:」是面向用户的自救说明,可直接转述。
|
|
63
|
+
|
|
64
|
+
## 使用纪律
|
|
65
|
+
|
|
66
|
+
1. **只读**:本 CLI 只提供课表/成绩/状态查询。选课、退课、评教提交等
|
|
67
|
+
有副作用的操作一律不提供——用户提出此类需求时说明超出能力范围,
|
|
68
|
+
不要尝试用任何方式绕过。
|
|
69
|
+
2. **凭据纪律**:密码只存在于用户本机 `~/.linke-cli/`(权限 600)。
|
|
70
|
+
不询问、不存储、不转发用户密码;需要录入时只引导用户本人跑
|
|
71
|
+
`linke config`。
|
|
72
|
+
3. **频次纪律**:教务系统对高频请求有风控,连续查询之间保持间隔,
|
|
73
|
+
不做轮询;一次问答内同一命令不重复执行超过 2 次。
|
|
74
|
+
4. 数据口径:成绩/课表数据实时来自教务系统,如与预期不符先用
|
|
75
|
+
`linke status` 确认登录身份(返回的 userInfo 含姓名/学院/专业/班级)。
|
package/src/bin.js
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 命令分发与参数解析(零依赖手写,不引 commander)。
|
|
3
|
+
* 输出契约:stdout 只有业务 JSON;进度与错误走 stderr;
|
|
4
|
+
* exit code 见 src/errors.js EXIT 表(与 SKILL.md 同步)。
|
|
5
|
+
*/
|
|
6
|
+
import { resolveConfig, saveConfig, clearConfig, redactConfig, DEFAULT_SCHOOL, DEFAULT_API_BASE } from './config.js'
|
|
7
|
+
import { configMissing, LinkeError } from './errors.js'
|
|
8
|
+
import { listAdapters, getAdapter } from './schools/registry.js'
|
|
9
|
+
import {
|
|
10
|
+
withSession,
|
|
11
|
+
login as forceLogin,
|
|
12
|
+
inspectSession,
|
|
13
|
+
clearSession,
|
|
14
|
+
} from './session.js'
|
|
15
|
+
import { installSkill, skillSourceDir } from './skill.js'
|
|
16
|
+
import { ask, askPassword } from './prompt.js'
|
|
17
|
+
import { emitJson, progress } from './util.js'
|
|
18
|
+
|
|
19
|
+
const USAGE = `linke —— 林课教务 CLI(只读查询,供 agent / 人类使用)
|
|
20
|
+
|
|
21
|
+
用法:
|
|
22
|
+
linke config [--school sdufe] [--api-base URL] 交互式录入学号/密码(存 ~/.linke-cli/,权限 600)
|
|
23
|
+
linke config --clear 清除本机凭据
|
|
24
|
+
linke status 配置与教务会话状态(JSON)
|
|
25
|
+
linke login 强制重新登录(状态机平时自动做)
|
|
26
|
+
linke scores [--term 2025-2026-1] 成绩查询(缺省=全部学期)
|
|
27
|
+
linke schedule [--term 2025-2026-1] [--week 3] 课表查询(缺省=当前学期全部周)
|
|
28
|
+
linke schools 列出可用学校适配器
|
|
29
|
+
linke skill install [--path ~/.agents/skills] 安装 agent skill 说明书
|
|
30
|
+
linke logout 清除本机教务 session(保留凭据)
|
|
31
|
+
linke help 本帮助
|
|
32
|
+
|
|
33
|
+
说明:
|
|
34
|
+
登录请求从本机直发教务系统;验证码图片仅用于云端识别(传图返文字)。
|
|
35
|
+
每个数据命令内置鉴权状态机:session 有效直接用,过期自动重登,
|
|
36
|
+
凭据失效才报错(exit 2,重新 linke config)。stdout 仅输出 JSON。`
|
|
37
|
+
|
|
38
|
+
function parseArgs(argv) {
|
|
39
|
+
const positional = []
|
|
40
|
+
const flags = {}
|
|
41
|
+
for (let i = 0; i < argv.length; i++) {
|
|
42
|
+
const token = argv[i]
|
|
43
|
+
if (!token.startsWith('--')) {
|
|
44
|
+
positional.push(token)
|
|
45
|
+
continue
|
|
46
|
+
}
|
|
47
|
+
const body = token.slice(2)
|
|
48
|
+
const eq = body.indexOf('=')
|
|
49
|
+
if (eq !== -1) {
|
|
50
|
+
flags[body.slice(0, eq)] = body.slice(eq + 1)
|
|
51
|
+
continue
|
|
52
|
+
}
|
|
53
|
+
if (i + 1 < argv.length && !argv[i + 1].startsWith('--')) {
|
|
54
|
+
flags[body] = argv[i + 1]
|
|
55
|
+
i += 1
|
|
56
|
+
} else {
|
|
57
|
+
flags[body] = true
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return { positional, flags }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function requireConfig() {
|
|
64
|
+
const config = resolveConfig()
|
|
65
|
+
if (!config) throw configMissing()
|
|
66
|
+
return config
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function cmdConfig(flags) {
|
|
70
|
+
if (flags.clear) {
|
|
71
|
+
clearConfig()
|
|
72
|
+
clearSession()
|
|
73
|
+
progress('已清除本机凭据与 session')
|
|
74
|
+
return 0
|
|
75
|
+
}
|
|
76
|
+
const school = String(flags.school || DEFAULT_SCHOOL)
|
|
77
|
+
getAdapter(school) // 校验适配器存在
|
|
78
|
+
const apiBase = String(flags['api-base'] || DEFAULT_API_BASE)
|
|
79
|
+
|
|
80
|
+
process.stderr.write(`配置林课教务凭据(学校适配器: ${school})\n`)
|
|
81
|
+
const userId = await ask('学号: ')
|
|
82
|
+
if (!userId) {
|
|
83
|
+
progress('学号为空,已取消')
|
|
84
|
+
return 1
|
|
85
|
+
}
|
|
86
|
+
const password = await askPassword('教务密码(输入不回显): ')
|
|
87
|
+
if (!password) {
|
|
88
|
+
progress('密码为空,已取消')
|
|
89
|
+
return 1
|
|
90
|
+
}
|
|
91
|
+
saveConfig({ school, userId, password, apiBase })
|
|
92
|
+
clearSession() // 凭据变更后旧 session 一律作废
|
|
93
|
+
progress(`已保存到 ~/.linke-cli/config.json(权限 600)。运行 linke login 验证,或直接运行任意查询命令。`)
|
|
94
|
+
return 0
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function cmdStatus() {
|
|
98
|
+
const config = resolveConfig()
|
|
99
|
+
const adapters = listAdapters()
|
|
100
|
+
if (!config) {
|
|
101
|
+
emitJson({ configured: false, adapters, skill: skillSourceDir() })
|
|
102
|
+
return 0
|
|
103
|
+
}
|
|
104
|
+
const sessionInfo = await inspectSession(config)
|
|
105
|
+
emitJson({
|
|
106
|
+
configured: true,
|
|
107
|
+
config: redactConfig(config),
|
|
108
|
+
adapters,
|
|
109
|
+
session: sessionInfo,
|
|
110
|
+
skill: skillSourceDir(),
|
|
111
|
+
})
|
|
112
|
+
return 0
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function cmdLogin() {
|
|
116
|
+
const config = requireConfig()
|
|
117
|
+
const adapter = getAdapter(config.school)
|
|
118
|
+
const session = await forceLogin(adapter, config)
|
|
119
|
+
emitJson({ ok: true, userInfo: session.userInfo })
|
|
120
|
+
return 0
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function cmdScores(flags) {
|
|
124
|
+
const config = requireConfig()
|
|
125
|
+
const term = flags.term ? String(flags.term) : ''
|
|
126
|
+
const rows = await withSession(config, (adapter, session) =>
|
|
127
|
+
adapter.fetchScores(session.cookie, { term })
|
|
128
|
+
)
|
|
129
|
+
emitJson(rows)
|
|
130
|
+
return 0
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function cmdSchedule(flags) {
|
|
134
|
+
const config = requireConfig()
|
|
135
|
+
let term = flags.term ? String(flags.term) : ''
|
|
136
|
+
const week = flags.week !== undefined ? String(flags.week) : ''
|
|
137
|
+
const result = await withSession(config, async (adapter, session) => {
|
|
138
|
+
if (!term) {
|
|
139
|
+
term = (await adapter.fetchCurrentTerm(session.cookie)) || ''
|
|
140
|
+
if (!term) progress('未能解析当前学期,交给教务默认值')
|
|
141
|
+
else progress(`当前学期: ${term}`)
|
|
142
|
+
}
|
|
143
|
+
const schedule = await adapter.fetchSchedule(session.cookie, { term, week })
|
|
144
|
+
return { term: term || null, week: week || null, ...schedule }
|
|
145
|
+
})
|
|
146
|
+
emitJson(result)
|
|
147
|
+
return 0
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function cmdSchools() {
|
|
151
|
+
emitJson(listAdapters())
|
|
152
|
+
return 0
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function cmdSkillInstall(flags) {
|
|
156
|
+
const explicit = flags.path && flags.path !== true ? String(flags.path) : ''
|
|
157
|
+
const installed = installSkill(explicit)
|
|
158
|
+
emitJson({ ok: true, installed })
|
|
159
|
+
return 0
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function cmdLogout() {
|
|
163
|
+
clearSession()
|
|
164
|
+
progress('已清除本机教务 session(凭据保留)')
|
|
165
|
+
return 0
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function printUsage() {
|
|
169
|
+
process.stderr.write(USAGE + '\n')
|
|
170
|
+
return 0
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export async function runCli(argv) {
|
|
174
|
+
try {
|
|
175
|
+
return await dispatch(argv)
|
|
176
|
+
} catch (err) {
|
|
177
|
+
if (err instanceof LinkeError) {
|
|
178
|
+
process.stderr.write(`[linke] ${err.code}: ${err.message}\n`)
|
|
179
|
+
if (err.hint) process.stderr.write(`提示: ${err.hint}\n`)
|
|
180
|
+
return err.exitCode
|
|
181
|
+
}
|
|
182
|
+
process.stderr.write(`[linke] UNEXPECTED: ${err && err.message ? err.message : err}\n`)
|
|
183
|
+
return 1
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function dispatch(argv) {
|
|
188
|
+
const { positional, flags } = parseArgs(argv)
|
|
189
|
+
const command = positional[0] || 'help'
|
|
190
|
+
|
|
191
|
+
if (flags.help || flags.h) return printUsage()
|
|
192
|
+
|
|
193
|
+
switch (command) {
|
|
194
|
+
case 'help':
|
|
195
|
+
return printUsage()
|
|
196
|
+
case 'config':
|
|
197
|
+
return cmdConfig(flags)
|
|
198
|
+
case 'status':
|
|
199
|
+
return cmdStatus()
|
|
200
|
+
case 'login':
|
|
201
|
+
return cmdLogin()
|
|
202
|
+
case 'scores':
|
|
203
|
+
return cmdScores(flags)
|
|
204
|
+
case 'schedule':
|
|
205
|
+
return cmdSchedule(flags)
|
|
206
|
+
case 'schools':
|
|
207
|
+
return cmdSchools()
|
|
208
|
+
case 'skill':
|
|
209
|
+
if (positional[1] !== 'install') return printUsage()
|
|
210
|
+
return cmdSkillInstall(flags)
|
|
211
|
+
case 'logout':
|
|
212
|
+
return cmdLogout()
|
|
213
|
+
default:
|
|
214
|
+
process.stderr.write(`未知命令: ${command}\n\n`)
|
|
215
|
+
return printUsage()
|
|
216
|
+
}
|
|
217
|
+
}
|
package/src/cloudOcr.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 云端验证码识别客户端。
|
|
3
|
+
*
|
|
4
|
+
* 合规定位(T3 上下文 ①):只使用「传图返文字」的无状态能力——
|
|
5
|
+
* 验证码图片 base64 发往林课云端 App.Captcha.Recognize,云端不接触
|
|
6
|
+
* 学号/密码/cookie;登录请求本身从用户本机直发教务系统。
|
|
7
|
+
*
|
|
8
|
+
* 签名口径与 App 端 repositories/appApi.js 同源:
|
|
9
|
+
* signMain = md5("Linke" + service + signTime),signTime 为 Unix 秒。
|
|
10
|
+
*/
|
|
11
|
+
import crypto from 'node:crypto'
|
|
12
|
+
import { networkError } from './errors.js'
|
|
13
|
+
|
|
14
|
+
const SERVICE = 'App.Captcha.Recognize'
|
|
15
|
+
const SIGN_KEY = 'Linke'
|
|
16
|
+
|
|
17
|
+
function buildSignedUrl(apiBase) {
|
|
18
|
+
const signTime = Math.floor(Date.now() / 1000)
|
|
19
|
+
const signMain = crypto.createHash('md5').update(SIGN_KEY + SERVICE + String(signTime)).digest('hex')
|
|
20
|
+
const base = apiBase.replace(/\?.*$/, '')
|
|
21
|
+
return `${base}?service=${encodeURIComponent(SERVICE)}&signMain=${signMain}&signTime=${signTime}`
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 识别验证码图片。
|
|
26
|
+
* @param {string} apiBase PhalApi 入口地址
|
|
27
|
+
* @param {string} imageBase64 纯 base64(可带 data URI 前缀,会剥离)
|
|
28
|
+
* @returns {Promise<string>} 识别文字(小写,教务验证码不区分大小写)
|
|
29
|
+
*/
|
|
30
|
+
export async function recognizeCaptcha(apiBase, imageBase64) {
|
|
31
|
+
const pure = imageBase64.includes(',') ? imageBase64.split(',')[1] || imageBase64 : imageBase64
|
|
32
|
+
let response
|
|
33
|
+
try {
|
|
34
|
+
response = await fetch(buildSignedUrl(apiBase), {
|
|
35
|
+
method: 'POST',
|
|
36
|
+
headers: { 'Content-Type': 'application/json;charset=utf-8' },
|
|
37
|
+
body: JSON.stringify({ image_base64: pure }),
|
|
38
|
+
signal: AbortSignal.timeout(20000),
|
|
39
|
+
})
|
|
40
|
+
} catch (err) {
|
|
41
|
+
throw networkError('云端验证码识别', err)
|
|
42
|
+
}
|
|
43
|
+
let body
|
|
44
|
+
try {
|
|
45
|
+
body = await response.json()
|
|
46
|
+
} catch (err) {
|
|
47
|
+
throw networkError('云端验证码识别(响应解析)', err)
|
|
48
|
+
}
|
|
49
|
+
if (body && body.ret == 200 && body.data) {
|
|
50
|
+
if (body.data.result) return String(body.data.result).toLowerCase()
|
|
51
|
+
if (body.data.error) throw new Error(`云端识别返回错误:${body.data.error}`)
|
|
52
|
+
}
|
|
53
|
+
throw new Error('云端识别返回格式异常')
|
|
54
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 本机配置与凭据存储。
|
|
3
|
+
*
|
|
4
|
+
* 安全口径(T3 验收 1):
|
|
5
|
+
* - 凭据只存 ~/.linke-cli/config.json,文件权限 0600、目录 0700;
|
|
6
|
+
* - 凭据只经 `linke config` 交互式录入,不提供命令行参数传入,
|
|
7
|
+
* 因此不会出现在 shell 历史与进程列表;
|
|
8
|
+
* - 任何日志/错误输出不包含 password 字段。
|
|
9
|
+
*/
|
|
10
|
+
import fs from 'node:fs'
|
|
11
|
+
import os from 'node:os'
|
|
12
|
+
import path from 'node:path'
|
|
13
|
+
|
|
14
|
+
export const DEFAULT_SCHOOL = 'sdufe'
|
|
15
|
+
export const DEFAULT_API_BASE = 'https://api.linketeam.com/Api/public/index.php'
|
|
16
|
+
|
|
17
|
+
export function configDir() {
|
|
18
|
+
return path.join(os.homedir(), '.linke-cli')
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function configPath() {
|
|
22
|
+
return path.join(configDir(), 'config.json')
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function sessionPath() {
|
|
26
|
+
return path.join(configDir(), 'session.json')
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function ensureDirPrivate(dir) {
|
|
30
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
31
|
+
// 目录收紧为 0700(仅当权限过宽时)
|
|
32
|
+
try {
|
|
33
|
+
const mode = fs.statSync(dir).mode & 0o777
|
|
34
|
+
if (mode !== 0o700) fs.chmodSync(dir, 0o700)
|
|
35
|
+
} catch {
|
|
36
|
+
/* 非 POSIX 文件系统时忽略 */
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** 读取配置;不存在返回 null */
|
|
41
|
+
export function loadConfig() {
|
|
42
|
+
const file = configPath()
|
|
43
|
+
if (!fs.existsSync(file)) return null
|
|
44
|
+
try {
|
|
45
|
+
const raw = fs.readFileSync(file, 'utf8')
|
|
46
|
+
const parsed = JSON.parse(raw)
|
|
47
|
+
if (!parsed || typeof parsed !== 'object') return null
|
|
48
|
+
return parsed
|
|
49
|
+
} catch {
|
|
50
|
+
return null
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* 写入配置。凭据字段落盘后立即收紧为 0600。
|
|
56
|
+
* 返回写好的配置(不含明文密码的日志版本由 redactConfig 生成)。
|
|
57
|
+
*/
|
|
58
|
+
export function saveConfig(config) {
|
|
59
|
+
ensureDirPrivate(configDir())
|
|
60
|
+
const file = configPath()
|
|
61
|
+
fs.writeFileSync(file, JSON.stringify(config, null, 2) + '\n', { mode: 0o600 })
|
|
62
|
+
fs.chmodSync(file, 0o600)
|
|
63
|
+
return config
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** 用于展示/日志的脱敏配置(密码只留长度) */
|
|
67
|
+
export function redactConfig(config) {
|
|
68
|
+
if (!config) return null
|
|
69
|
+
return {
|
|
70
|
+
school: config.school,
|
|
71
|
+
userId: config.userId,
|
|
72
|
+
passwordLength: config.password ? String(config.password).length : 0,
|
|
73
|
+
apiBase: config.apiBase || DEFAULT_API_BASE,
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** 解析生效配置:合并默认值;未配置返回 null */
|
|
78
|
+
export function resolveConfig() {
|
|
79
|
+
const config = loadConfig()
|
|
80
|
+
if (!config || !config.userId || !config.password) return null
|
|
81
|
+
return {
|
|
82
|
+
school: config.school || DEFAULT_SCHOOL,
|
|
83
|
+
userId: config.userId,
|
|
84
|
+
password: config.password,
|
|
85
|
+
apiBase: config.apiBase || DEFAULT_API_BASE,
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** 清除凭据配置文件 */
|
|
90
|
+
export function clearConfig() {
|
|
91
|
+
const file = configPath()
|
|
92
|
+
if (fs.existsSync(file)) fs.rmSync(file)
|
|
93
|
+
}
|
package/src/errors.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 统一错误分类。exit code 契约同时写进了 skill 说明书(skills/linke/SKILL.md),
|
|
3
|
+
* 改动这里必须同步改说明书。
|
|
4
|
+
*/
|
|
5
|
+
export class LinkeError extends Error {
|
|
6
|
+
/**
|
|
7
|
+
* @param {string} code 机器可读错误码
|
|
8
|
+
* @param {string} message 人类可读信息(不得包含密码等凭据)
|
|
9
|
+
* @param {object} options
|
|
10
|
+
* @param {number} options.exitCode
|
|
11
|
+
* @param {string} options.hint 自救提示(给 agent / 用户看)
|
|
12
|
+
* @param {boolean} options.isLoginExpired 内部标记:教务 session 失效,可重登重试
|
|
13
|
+
*/
|
|
14
|
+
constructor(code, message, options = {}) {
|
|
15
|
+
super(message)
|
|
16
|
+
this.name = 'LinkeError'
|
|
17
|
+
this.code = code
|
|
18
|
+
this.exitCode = options.exitCode ?? 1
|
|
19
|
+
this.hint = options.hint ?? ''
|
|
20
|
+
this.isLoginExpired = options.isLoginExpired ?? false
|
|
21
|
+
this.cause = options.cause
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const EXIT = {
|
|
26
|
+
OK: 0,
|
|
27
|
+
GENERAL: 1,
|
|
28
|
+
CREDENTIAL_INVALID: 2,
|
|
29
|
+
LOGIN_RETRY_EXHAUSTED: 3,
|
|
30
|
+
NETWORK: 4,
|
|
31
|
+
PARSE: 5,
|
|
32
|
+
NOT_CONFIGURED: 6,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function configMissing() {
|
|
36
|
+
return new LinkeError('CONFIG_MISSING', '尚未配置教务账号凭据', {
|
|
37
|
+
exitCode: EXIT.NOT_CONFIGURED,
|
|
38
|
+
hint: '请先运行: linke config (交互式录入学号与密码,凭据只存本机 ~/.linke-cli/,权限 600)',
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function credentialInvalid(detail = '') {
|
|
43
|
+
return new LinkeError('CREDENTIAL_INVALID', `教务账号或密码错误${detail ? `(${detail})` : ''}`, {
|
|
44
|
+
exitCode: EXIT.CREDENTIAL_INVALID,
|
|
45
|
+
hint: '请重新运行: linke config 录入正确凭据',
|
|
46
|
+
})
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function loginRetryExhausted(attempts) {
|
|
50
|
+
return new LinkeError('LOGIN_RETRY_EXHAUSTED', `验证码自动识别连续 ${attempts} 次未通过,登录未完成`, {
|
|
51
|
+
exitCode: EXIT.LOGIN_RETRY_EXHAUSTED,
|
|
52
|
+
hint: '稍后重试同一命令即可(会自动重登);若持续失败,可能是云端识别服务或教务系统异常,运行 linke status 检查',
|
|
53
|
+
})
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function networkError(action, cause) {
|
|
57
|
+
return new LinkeError('NETWORK', `${action}失败:网络不可达或服务异常`, {
|
|
58
|
+
exitCode: EXIT.NETWORK,
|
|
59
|
+
hint: '检查本机网络(教务系统为校内可达的 http://jw.sdufe.edu.cn,云端识别为 https://api.linketeam.com)后重试',
|
|
60
|
+
cause,
|
|
61
|
+
})
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function parseError(what) {
|
|
65
|
+
return new LinkeError('PARSE', `解析${what}失败:教务页面结构可能已变化`, {
|
|
66
|
+
exitCode: EXIT.PARSE,
|
|
67
|
+
hint: '这是适配器正则与教务页面脱节,请到 linke-cli 仓库提 issue 注明发生时间',
|
|
68
|
+
})
|
|
69
|
+
}
|
package/src/prompt.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 交互式输入(TTY)。密码输入不回显——不依赖第三方库,
|
|
3
|
+
* 用 readline + 自定义 output 实现(非 TTY 环境直接报错,
|
|
4
|
+
* 拒绝从管道读密码以免凭据进入 shell 历史/日志链路)。
|
|
5
|
+
*/
|
|
6
|
+
import readline from 'node:readline'
|
|
7
|
+
import { Writable } from 'node:stream'
|
|
8
|
+
import { LinkeError, EXIT } from './errors.js'
|
|
9
|
+
|
|
10
|
+
export function ask(question) {
|
|
11
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
|
|
12
|
+
return new Promise((resolve) => {
|
|
13
|
+
rl.question(question, (answer) => {
|
|
14
|
+
rl.close()
|
|
15
|
+
resolve(String(answer || '').trim())
|
|
16
|
+
})
|
|
17
|
+
})
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function askPassword(question) {
|
|
21
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
22
|
+
throw new LinkeError(
|
|
23
|
+
'NO_TTY',
|
|
24
|
+
'当前环境不是交互式终端,无法安全录入密码',
|
|
25
|
+
{ exitCode: EXIT.GENERAL, hint: '请在真实终端中运行: linke config' }
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
// 提示写真实 stdout,回显走哑流:密码字符不落到终端,也就进不了终端回滚缓冲
|
|
29
|
+
process.stdout.write(question)
|
|
30
|
+
const devnull = new Writable({ write(_chunk, _enc, cb) { cb() } })
|
|
31
|
+
const rl = readline.createInterface({ input: process.stdin, output: devnull })
|
|
32
|
+
return new Promise((resolve) => {
|
|
33
|
+
rl.question('', (answer) => {
|
|
34
|
+
process.stdout.write('\n')
|
|
35
|
+
rl.close()
|
|
36
|
+
resolve(String(answer || ''))
|
|
37
|
+
})
|
|
38
|
+
})
|
|
39
|
+
}
|