linke-sdufe 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 +69 -0
- package/package.json +33 -0
- package/src/adapter.js +496 -0
- package/src/encoding.js +24 -0
- package/src/env.js +60 -0
- package/src/errors.js +64 -0
- package/src/index.js +11 -0
- package/src/parsers.js +825 -0
- package/src/registry.js +51 -0
- package/src/util.js +52 -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,69 @@
|
|
|
1
|
+
# linke-sdufe
|
|
2
|
+
|
|
3
|
+
山东财经大学强智教务(`jw.sdufe.edu.cn`)适配器共享包:登录流程、
|
|
4
|
+
教务查询与页面解析。`linke-cli`(Node)与 Linke App(uni-app)同源
|
|
5
|
+
引用同一份适配器代码(T24 抽包,源自 linke-cli `src/schools/`)。
|
|
6
|
+
|
|
7
|
+
## 链路纪律(不变式)
|
|
8
|
+
|
|
9
|
+
- 对 `jw.sdufe.edu.cn` 的所有请求**从用户设备直发**,本包不引入任何服务器代抓;
|
|
10
|
+
- 云端只收验证码图片 base64(识别回调由宿主注入,本包不感知识别实现);
|
|
11
|
+
- 教务请求 UA 固定伪装值,不携带 CLI/App 客户端标记(客户端标记只用于
|
|
12
|
+
林课自有 API,与 linke-cli 的静态断言口径一致)。
|
|
13
|
+
|
|
14
|
+
## 环境契约(env 四件)
|
|
15
|
+
|
|
16
|
+
适配器核心不触碰宿主专有 API,`createSdufeAdapter(env)` 注入:
|
|
17
|
+
|
|
18
|
+
| 能力 | 签名 | 说明 |
|
|
19
|
+
|---|---|---|
|
|
20
|
+
| fetch | `fetch(url, {method, headers, body, redirect, timeoutMs, expect}) → Promise<Response-like>` | `expect='buffer'` 表示将调 `arrayBuffer()`(uni.request 单 responseType 现实约束);Response-like 需 `{status, ok, headers.getSetCookie(), text(), arrayBuffer()}`;网络层失败 reject |
|
|
21
|
+
| toBase64 | `(bytes:Uint8Array) → string` | 验证码图片上行 |
|
|
22
|
+
| bytesToText | `(bytes:Uint8Array) → string` | 前 200 字节嗅探(宽松实现即可,ASCII 特征判定) |
|
|
23
|
+
| progress | `(msg:string) → void`(可缺省) | 进度输出 |
|
|
24
|
+
|
|
25
|
+
Node 宿主(>= 18.14)直接用内置 `nodeEnv()`;uni-app 宿主自行实现
|
|
26
|
+
uni.request 垫片(Linke App 侧参考实现:`linke_App/services/sdufePilot/`)。
|
|
27
|
+
|
|
28
|
+
## 用法
|
|
29
|
+
|
|
30
|
+
```js
|
|
31
|
+
// Node(linke-cli 同款)
|
|
32
|
+
import { initSdufe, getAdapter, nodeEnv } from 'linke-sdufe'
|
|
33
|
+
initSdufe(nodeEnv())
|
|
34
|
+
const adapter = getAdapter('sdufe')
|
|
35
|
+
const { cookie, userInfo } = await adapter.login(
|
|
36
|
+
{ userId, password, recognizeCaptcha: (b64) => cloudRecognize(b64) },
|
|
37
|
+
{ maxRetries: 3 }
|
|
38
|
+
)
|
|
39
|
+
const schedule = await adapter.fetchSchedule(cookie, { term: '', week: '' })
|
|
40
|
+
|
|
41
|
+
// uni-app(垫片注入)
|
|
42
|
+
import { initSdufe } from 'linke-sdufe'
|
|
43
|
+
import { uniEnv } from '@/services/sdufePilot/uniEnv.js'
|
|
44
|
+
const adapter = initSdufe(uniEnv)
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
适配器无持久状态:cookie 由调用方逐次传入/传出(`request` 返回
|
|
48
|
+
`nextCookie`),会话持久化与过期重登状态机归宿主(linke-cli 的
|
|
49
|
+
`session.js` / App 的 storage 方案)。
|
|
50
|
+
|
|
51
|
+
## 包边界
|
|
52
|
+
|
|
53
|
+
只管教务交互与解析。linke-cli 的业务层(凭据落盘、userKey、回流、
|
|
54
|
+
自动更新、写命令、cloudOcr 客户端)**不随包走**;错误分类
|
|
55
|
+
(`LinkeError`/`EXIT`)由本包持有,linke-cli re-export 并补充 CLI 专属
|
|
56
|
+
错误,保持单一实现。
|
|
57
|
+
|
|
58
|
+
版本与发版随 linke-cli 仓库(monorepo workspaces,`packages/sdufe`),
|
|
59
|
+
发布名 `linke-sdufe`(npm public,代码本就随 linke-cli 开源,无新增暴露)。
|
|
60
|
+
|
|
61
|
+
## 测试
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
npm test -w linke-sdufe # 或 cd packages/sdufe && npm test
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
env 契约与注入式适配器行为均以 mock env 测试,零网络依赖;页面解析器
|
|
68
|
+
的正则用例由 linke-cli 根测试套(`test/parsers*.test.mjs` 等)覆盖,
|
|
69
|
+
import 路径自 T24 起指向本包。
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "linke-sdufe",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "山东财经大学强智教务(jw.sdufe.edu.cn)适配器共享包:登录、教务查询与页面解析。linke-cli 与 Linke App 同源引用;教务请求一律从用户设备直发。",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"src",
|
|
12
|
+
"README.md",
|
|
13
|
+
"LICENSE"
|
|
14
|
+
],
|
|
15
|
+
"scripts": {
|
|
16
|
+
"test": "node --test test/*.test.mjs"
|
|
17
|
+
},
|
|
18
|
+
"keywords": [
|
|
19
|
+
"sdufe",
|
|
20
|
+
"linke",
|
|
21
|
+
"jw",
|
|
22
|
+
"kingosoft"
|
|
23
|
+
],
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=18.14"
|
|
26
|
+
},
|
|
27
|
+
"license": "MIT",
|
|
28
|
+
"repository": {
|
|
29
|
+
"type": "git",
|
|
30
|
+
"url": "git+https://github.com/ChuHanModel/linke-cli.git",
|
|
31
|
+
"directory": "packages/sdufe"
|
|
32
|
+
}
|
|
33
|
+
}
|
package/src/adapter.js
ADDED
|
@@ -0,0 +1,496 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 山财(sdufe)教务适配器:登录流程与数据抓取。
|
|
3
|
+
* 流程移植自 linke_App/services/auth/loginFlowService.js 与
|
|
4
|
+
* linke_App/utils/jwAutoLogin.js(本机直连教务的现役实现),
|
|
5
|
+
* 差异:验证码识别改调 cloudOcr(App 内同走 App.Captcha.Recognize)。
|
|
6
|
+
*
|
|
7
|
+
* 链路纪律:对 jw.sdufe.edu.cn 的所有请求都从用户设备发出;
|
|
8
|
+
* 云端只收验证码图片 base64(传图返文字)。
|
|
9
|
+
*
|
|
10
|
+
* 环境注入:本模块不触碰宿主专有 API(fetch/Buffer/process 均经 env),
|
|
11
|
+
* 由 createSdufeAdapter(env) 注入——Node 宿主用 nodeEnv(),uni-app 宿主
|
|
12
|
+
* 提供 uni.request 垫片(见 linke-sdufe README)。
|
|
13
|
+
*/
|
|
14
|
+
import { extractCookieHeader, isJwLoginExpired } from './util.js'
|
|
15
|
+
import { networkError, credentialInvalid, loginRetryExhausted, LinkeError, EXIT } from './errors.js'
|
|
16
|
+
import { computeEncoded } from './encoding.js'
|
|
17
|
+
import { validateEnv } from './env.js'
|
|
18
|
+
import {
|
|
19
|
+
parseUserData,
|
|
20
|
+
hasAuthenticatedProfileMarkers,
|
|
21
|
+
parseCurrentTerm,
|
|
22
|
+
parseScheduleHtml,
|
|
23
|
+
parseScoresHtml,
|
|
24
|
+
parseCreditsHtml,
|
|
25
|
+
parseCoursesHtml,
|
|
26
|
+
parseGpaHtml,
|
|
27
|
+
parseXjHtml,
|
|
28
|
+
parsePlanHtml,
|
|
29
|
+
parsePyfaHtml,
|
|
30
|
+
parseExamsHtml,
|
|
31
|
+
parseProgressPlansHtml,
|
|
32
|
+
parseProgressDetailHtml,
|
|
33
|
+
parseSimpleTable,
|
|
34
|
+
parseJwcNotices,
|
|
35
|
+
parseMakeupsHtml,
|
|
36
|
+
} from './parsers.js'
|
|
37
|
+
|
|
38
|
+
const USER_AGENT = 'Apifox/1.0.0 (https://apifox.com)'
|
|
39
|
+
const REQUEST_TIMEOUT_MS = 20000
|
|
40
|
+
|
|
41
|
+
/** 课程属性中文名 → zzdKcSX 表单码(强智 kbxx_kc_ifr 口径) */
|
|
42
|
+
export const COURSE_TYPE_MAP = {
|
|
43
|
+
必修: '1',
|
|
44
|
+
实践选修: '2',
|
|
45
|
+
专选: '3',
|
|
46
|
+
通选: '4',
|
|
47
|
+
实践必修: '5',
|
|
48
|
+
其它: '9',
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** 考试类别中文名 → xqlb 码;xqlbmc 须为选中项文本(表单页 JS 口径) */
|
|
52
|
+
const EXAM_KIND_MAP = { 期初: '1', 期中: '2', 期末: '3' }
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* 创建 sdufe 适配器实例。
|
|
56
|
+
* @param {object} env 宿主环境四件(fetch/toBase64/bytesToText/progress,
|
|
57
|
+
* 契约见 env.js;progress 可缺省为静默)
|
|
58
|
+
*/
|
|
59
|
+
export function createSdufeAdapter(env) {
|
|
60
|
+
validateEnv(env)
|
|
61
|
+
const progressOut = typeof env.progress === 'function' ? env.progress : () => {}
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
id: 'sdufe',
|
|
65
|
+
name: '山东财经大学(强智教务)',
|
|
66
|
+
baseUrl: 'http://jw.sdufe.edu.cn',
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* 教务 HTTP 请求(cookie 手动管理,redirect 跟随——与 uni.request 默认行为一致)。
|
|
70
|
+
* 返回 { status, text, buffer, nextCookie }。
|
|
71
|
+
*/
|
|
72
|
+
async request(url, method, { body, cookie, expect = 'text' } = {}) {
|
|
73
|
+
let response
|
|
74
|
+
try {
|
|
75
|
+
response = await env.fetch(url, {
|
|
76
|
+
method,
|
|
77
|
+
redirect: 'follow',
|
|
78
|
+
expect,
|
|
79
|
+
headers: {
|
|
80
|
+
'User-Agent': USER_AGENT,
|
|
81
|
+
Accept: '*/*',
|
|
82
|
+
Connection: 'keep-alive',
|
|
83
|
+
...(cookie ? { Cookie: cookie } : {}),
|
|
84
|
+
...(body !== undefined ? { 'Content-Type': 'application/x-www-form-urlencoded' } : {}),
|
|
85
|
+
},
|
|
86
|
+
body,
|
|
87
|
+
timeoutMs: REQUEST_TIMEOUT_MS,
|
|
88
|
+
})
|
|
89
|
+
} catch (err) {
|
|
90
|
+
throw networkError('请求教务系统', err)
|
|
91
|
+
}
|
|
92
|
+
const nextCookie = extractCookieHeader(response)
|
|
93
|
+
if (expect === 'buffer') {
|
|
94
|
+
return { status: response.status, buffer: new Uint8Array(await response.arrayBuffer()), nextCookie }
|
|
95
|
+
}
|
|
96
|
+
return { status: response.status, text: await response.text(), nextCookie }
|
|
97
|
+
},
|
|
98
|
+
|
|
99
|
+
/** 获取会话种子(scode/sxh)与初始 Cookie */
|
|
100
|
+
async fetchSeed() {
|
|
101
|
+
const res = await this.request(`${this.baseUrl}/Logon.do?method=logon&flag=sess`, 'POST')
|
|
102
|
+
const parts = String(res.text || '').trim().split('#')
|
|
103
|
+
if (parts.length < 2 || !res.nextCookie) {
|
|
104
|
+
throw new LinkeError('SEED_FAILED', '获取教务会话种子失败', {
|
|
105
|
+
exitCode: EXIT.NETWORK,
|
|
106
|
+
hint: '教务系统可能暂不可达,稍后重试',
|
|
107
|
+
})
|
|
108
|
+
}
|
|
109
|
+
return { seedScode: parts[0] || '', seedSxh: parts[1] || '', cookie: res.nextCookie }
|
|
110
|
+
},
|
|
111
|
+
|
|
112
|
+
/** 获取验证码图片 base64 */
|
|
113
|
+
async fetchCaptcha(cookie) {
|
|
114
|
+
const res = await this.request(`${this.baseUrl}/verifycode.servlet`, 'GET', {
|
|
115
|
+
cookie,
|
|
116
|
+
expect: 'buffer',
|
|
117
|
+
})
|
|
118
|
+
const textPeek = env.bytesToText(res.buffer.subarray(0, 200))
|
|
119
|
+
if (textPeek.includes('</html>')) {
|
|
120
|
+
throw new LinkeError('CAPTCHA_FAILED', '获取验证码图片失败(教务返回了页面而非图片)', {
|
|
121
|
+
exitCode: EXIT.NETWORK,
|
|
122
|
+
hint: '稍后重试;若持续出现,教务可能正在维护',
|
|
123
|
+
})
|
|
124
|
+
}
|
|
125
|
+
return env.toBase64(res.buffer)
|
|
126
|
+
},
|
|
127
|
+
|
|
128
|
+
/** 提交登录。验证码错误 → isCaptchaError;密码错误 → isPasswordError */
|
|
129
|
+
async submitLogin({ userId, password, captcha, cookie, seedScode, seedSxh }) {
|
|
130
|
+
const encoded = computeEncoded(userId, password, seedScode, seedSxh)
|
|
131
|
+
if (!encoded) throw new Error('登录参数计算失败')
|
|
132
|
+
const form = [
|
|
133
|
+
['userAccount', userId],
|
|
134
|
+
['userPassword', password],
|
|
135
|
+
['RANDOMCODE', captcha],
|
|
136
|
+
['encoded', encoded],
|
|
137
|
+
]
|
|
138
|
+
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
|
|
139
|
+
.join('&')
|
|
140
|
+
const res = await this.request(`${this.baseUrl}/Logon.do?method=logon`, 'POST', {
|
|
141
|
+
body: form,
|
|
142
|
+
cookie,
|
|
143
|
+
})
|
|
144
|
+
const text = res.text || ''
|
|
145
|
+
if (text.includes('验证码错误')) {
|
|
146
|
+
const err = new Error('验证码错误')
|
|
147
|
+
err.isCaptchaError = true
|
|
148
|
+
throw err
|
|
149
|
+
}
|
|
150
|
+
if (text.includes('密码错误')) {
|
|
151
|
+
const err = new Error('账号或密码错误')
|
|
152
|
+
err.isPasswordError = true
|
|
153
|
+
throw err
|
|
154
|
+
}
|
|
155
|
+
return res
|
|
156
|
+
},
|
|
157
|
+
|
|
158
|
+
/** 获取个人主页 HTML(登录确认 / session 探活) */
|
|
159
|
+
async fetchProfileHtml(cookie) {
|
|
160
|
+
const res = await this.request(`${this.baseUrl}/jsxsd/framework/xsMain_new.jsp`, 'GET', { cookie })
|
|
161
|
+
return res.text || ''
|
|
162
|
+
},
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* 完整登录:种子 → 验证码 → 云端识别 → 提交 → 主页确认。
|
|
166
|
+
* 验证码识别/验证码错误自动重试(整体重新取种子+验证码,maxRetries 次)。
|
|
167
|
+
* @returns {{ cookie: string, userInfo: object }}
|
|
168
|
+
*/
|
|
169
|
+
async login({ userId, password, recognizeCaptcha }, { maxRetries = 3, onProgress = progressOut } = {}) {
|
|
170
|
+
let lastCaptchaError = null
|
|
171
|
+
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
172
|
+
onProgress(`获取教务会话(第 ${attempt}/${maxRetries} 次)...`)
|
|
173
|
+
const { seedScode, seedSxh, cookie } = await this.fetchSeed()
|
|
174
|
+
try {
|
|
175
|
+
onProgress('获取验证码图片...')
|
|
176
|
+
const captchaBase64 = await this.fetchCaptcha(cookie)
|
|
177
|
+
onProgress('云端识别验证码...')
|
|
178
|
+
const captcha = await recognizeCaptcha(captchaBase64)
|
|
179
|
+
if (!captcha) throw Object.assign(new Error('识别结果为空'), { isCaptchaError: true })
|
|
180
|
+
|
|
181
|
+
onProgress('提交登录...')
|
|
182
|
+
try {
|
|
183
|
+
await this.submitLogin({ userId, password, captcha, cookie, seedScode, seedSxh })
|
|
184
|
+
} catch (err) {
|
|
185
|
+
if (err.isCaptchaError) throw err
|
|
186
|
+
if (err.isPasswordError) throw credentialInvalid('教务返回密码错误')
|
|
187
|
+
throw err
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
onProgress('确认登录状态...')
|
|
191
|
+
const html = await this.fetchProfileHtml(cookie)
|
|
192
|
+
// 假登录检测(App 1.0.6/1.0.8 修复口径)
|
|
193
|
+
const isLoginPage =
|
|
194
|
+
typeof html === 'string' &&
|
|
195
|
+
html.includes('RANDOMCODE') &&
|
|
196
|
+
(html.includes('userAccount') || html.includes('userPassword'))
|
|
197
|
+
const userInfo = parseUserData(html)
|
|
198
|
+
if (isLoginPage || isJwLoginExpired(html) || !hasAuthenticatedProfileMarkers(html, userInfo)) {
|
|
199
|
+
throw Object.assign(new Error('假登录:主页未命中已登录特征'), { isCaptchaError: true })
|
|
200
|
+
}
|
|
201
|
+
return { cookie, userInfo }
|
|
202
|
+
} catch (err) {
|
|
203
|
+
if (err.isCaptchaError && attempt < maxRetries) {
|
|
204
|
+
lastCaptchaError = err
|
|
205
|
+
onProgress('验证码未通过,刷新重试...')
|
|
206
|
+
continue
|
|
207
|
+
}
|
|
208
|
+
if (err.isCaptchaError) {
|
|
209
|
+
lastCaptchaError = err
|
|
210
|
+
break
|
|
211
|
+
}
|
|
212
|
+
throw err
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
throw loginRetryExhausted(maxRetries)
|
|
216
|
+
},
|
|
217
|
+
|
|
218
|
+
/** 解析当前学期(课表页 select),失败返回 null */
|
|
219
|
+
async fetchCurrentTerm(cookie) {
|
|
220
|
+
const res = await this.request(`${this.baseUrl}/jsxsd/xskb/xskb_list.do`, 'GET', { cookie })
|
|
221
|
+
return parseCurrentTerm(res.text || '')
|
|
222
|
+
},
|
|
223
|
+
|
|
224
|
+
/** 抓课表:term 空 = 教务默认学期;week 空 = 全部周 */
|
|
225
|
+
async fetchSchedule(cookie, { term = '', week = '' } = {}) {
|
|
226
|
+
const form = `xnxq01id=${encodeURIComponent(term)}&zc=${encodeURIComponent(String(week))}`
|
|
227
|
+
const res = await this.request(`${this.baseUrl}/jsxsd/xskb/xskb_list.do`, 'POST', {
|
|
228
|
+
body: form,
|
|
229
|
+
cookie,
|
|
230
|
+
})
|
|
231
|
+
return parseScheduleHtml(res.text || '')
|
|
232
|
+
},
|
|
233
|
+
|
|
234
|
+
/** 抓成绩:term 空 = 全部学期 */
|
|
235
|
+
async fetchScores(cookie, { term = '' } = {}) {
|
|
236
|
+
const form = `kksj=${encodeURIComponent(term)}&xsfs=all`
|
|
237
|
+
const res = await this.request(`${this.baseUrl}/jsxsd/kscj/cjcx_list`, 'POST', {
|
|
238
|
+
body: form,
|
|
239
|
+
cookie,
|
|
240
|
+
})
|
|
241
|
+
return parseScoresHtml(res.text || '')
|
|
242
|
+
},
|
|
243
|
+
|
|
244
|
+
/** 抓学分修读(通选课统计,GET 直出) */
|
|
245
|
+
async fetchCredits(cookie) {
|
|
246
|
+
const res = await this.request(`${this.baseUrl}/jsxsd/xxwcqk/xstxkxdqk.do`, 'GET', { cookie })
|
|
247
|
+
return parseCreditsHtml(res.text || '')
|
|
248
|
+
},
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* 课程课表查询网格(全校课程维度,POST kbxx_kc_ifr)。
|
|
252
|
+
* @param {object} q { term 学期, type 课程属性(中文名或码), department
|
|
253
|
+
* 开课院系代码, courseName 课程名, teacher 教师 }——空参数不下发
|
|
254
|
+
* (规格书样例口径:仅带非空字段)
|
|
255
|
+
*/
|
|
256
|
+
async fetchCourses(cookie, { term = '', type = '', department = '', courseName = '', teacher = '' } = {}) {
|
|
257
|
+
const typeCode = COURSE_TYPE_MAP[type] || String(type || '')
|
|
258
|
+
const pairs = [
|
|
259
|
+
['xnxqh', term],
|
|
260
|
+
['kkyx', department],
|
|
261
|
+
['zzdKcSX', typeCode],
|
|
262
|
+
['kc', courseName],
|
|
263
|
+
['skls', teacher],
|
|
264
|
+
].filter(([, v]) => v !== '' && v !== undefined && v !== null)
|
|
265
|
+
const form = pairs.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join('&')
|
|
266
|
+
const res = await this.request(`${this.baseUrl}/jsxsd/kbcx/kbxx_kc_ifr`, 'POST', {
|
|
267
|
+
body: form,
|
|
268
|
+
cookie,
|
|
269
|
+
})
|
|
270
|
+
return parseCoursesHtml(res.text || '')
|
|
271
|
+
},
|
|
272
|
+
|
|
273
|
+
/** 平均学分绩点(含辅修行,GET 直出) */
|
|
274
|
+
async fetchGpa(cookie) {
|
|
275
|
+
const res = await this.request(`${this.baseUrl}/jsxsd/kscj/cjcx_avg`, 'GET', { cookie })
|
|
276
|
+
return parseGpaHtml(res.text || '')
|
|
277
|
+
},
|
|
278
|
+
|
|
279
|
+
/** 学籍卡片(默认裁剪敏感字段,full=true 输出白名单内扩展字段) */
|
|
280
|
+
async fetchXj(cookie, { full = false } = {}) {
|
|
281
|
+
const res = await this.request(`${this.baseUrl}/jsxsd/grxx/xsxx`, 'GET', { cookie })
|
|
282
|
+
return parseXjHtml(res.text || '', { full })
|
|
283
|
+
},
|
|
284
|
+
|
|
285
|
+
/** 培养执行计划(GET 直出,逐学期课程列表) */
|
|
286
|
+
async fetchPlan(cookie) {
|
|
287
|
+
const res = await this.request(`${this.baseUrl}/jsxsd/pyfa/pyfa_query`, 'GET', { cookie })
|
|
288
|
+
return parsePlanHtml(res.text || '')
|
|
289
|
+
},
|
|
290
|
+
|
|
291
|
+
/** 培养方案明细(GET 直出 75KB,畸形标记专用流式解析) */
|
|
292
|
+
async fetchPyfa(cookie) {
|
|
293
|
+
const res = await this.request(`${this.baseUrl}/jsxsd/pyfa/topyfamx`, 'GET', { cookie })
|
|
294
|
+
return parsePyfaHtml(res.text || '')
|
|
295
|
+
},
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* 考试安排(T11 口径攻坚成果):真实数据端点是 xsksap_list——
|
|
299
|
+
* 表单页 JS queryKsap() 会把 action 从空改写为此并填 xqlbmc 文本,
|
|
300
|
+
* 直 POST xsksap_query 只回表单页。
|
|
301
|
+
* @param {object} q { term 学期, kind 期初|期中|期末(空=全部) }
|
|
302
|
+
*/
|
|
303
|
+
async fetchExams(cookie, { term = '', kind = '' } = {}) {
|
|
304
|
+
const xqlb = EXAM_KIND_MAP[kind] || String(kind || '')
|
|
305
|
+
const body =
|
|
306
|
+
`xnxqid=${encodeURIComponent(term)}&xqlb=${encodeURIComponent(xqlb)}` +
|
|
307
|
+
`&xqlbmc=${encodeURIComponent(kind || '')}`
|
|
308
|
+
const res = await this.request(`${this.baseUrl}/jsxsd/xsks/xsksap_list`, 'POST', { body, cookie })
|
|
309
|
+
return parseExamsHtml(res.text || '')
|
|
310
|
+
},
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* 学业完成情况(T11 口径攻坚成果):两步——GET 入口页取修读方案
|
|
314
|
+
* (每方案一个 form,主修带 ndzydm/辅修带 fxzydm 隐藏码),再逐个
|
|
315
|
+
* POST xxwcqkOnkcxz.do 取双表数据。入口真实 URL 带 xxwcqk_ 前缀
|
|
316
|
+
* (全菜单树文档的 xstxkxdqk_ 前缀为误记)。
|
|
317
|
+
*/
|
|
318
|
+
async fetchProgress(cookie) {
|
|
319
|
+
const entry = await this.request(`${this.baseUrl}/jsxsd/xxwcqk/xxwcqk_idxOnxz.do`, 'GET', { cookie })
|
|
320
|
+
const { plans } = parseProgressPlansHtml(entry.text || '')
|
|
321
|
+
const results = []
|
|
322
|
+
for (const plan of plans) {
|
|
323
|
+
const body = `${plan.codeField}=${encodeURIComponent(plan.code)}&jx0301zxjhid=`
|
|
324
|
+
const res = await this.request(`${this.baseUrl}/jsxsd/xxwcqk/xxwcqkOnkcxz.do`, 'POST', { body, cookie })
|
|
325
|
+
const detail = parseProgressDetailHtml(res.text || '')
|
|
326
|
+
results.push({ type: plan.type, name: plan.name, ...detail })
|
|
327
|
+
}
|
|
328
|
+
return { plans: results }
|
|
329
|
+
},
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* T13 通用简表页抓取(GET 直出 + pageIndex 翻页)。
|
|
333
|
+
* @param {string} path 教务路径(如 /jsxsd/xsxj/xsydxx.do)
|
|
334
|
+
* @param {object} options { pageIndex 页码(默认 1),dropFirst
|
|
335
|
+
* 数据行首列丢弃(changes 展开图标列),tableIndex 取第几张含
|
|
336
|
+
* th 的表(social 第二张为考级成绩) }
|
|
337
|
+
*/
|
|
338
|
+
/** T14 学科竞赛(POST xkjsbm_list,jsmc 竞赛名/jsnf 年份,空=全部) */
|
|
339
|
+
async fetchContests(cookie, { name = '', year = '' } = {}) {
|
|
340
|
+
const body = `jsmc=${encodeURIComponent(name)}&jsnf=${encodeURIComponent(year)}`
|
|
341
|
+
const res = await this.request(`${this.baseUrl}/jsxsd/xsxkjs/xkjsbm_list`, 'POST', { body, cookie })
|
|
342
|
+
return parseSimpleTable(res.text || '')
|
|
343
|
+
},
|
|
344
|
+
|
|
345
|
+
/** T14 教学进度/授课计划(POST skjhxx,xnxqid 必填——JS 校验同口径) */
|
|
346
|
+
async fetchSyllabusQuery(cookie, { term = '', course = '', teacher = '', department = '' } = {}) {
|
|
347
|
+
const body =
|
|
348
|
+
`kcmc=${encodeURIComponent(course)}&kkyx=${encodeURIComponent(department)}` +
|
|
349
|
+
`&skjs=${encodeURIComponent(teacher)}&xnxqid=${encodeURIComponent(term)}`
|
|
350
|
+
const res = await this.request(`${this.baseUrl}/jsxsd/xkgl/skjhxx`, 'POST', { body, cookie })
|
|
351
|
+
return parseSimpleTable(res.text || '')
|
|
352
|
+
},
|
|
353
|
+
|
|
354
|
+
/** T14 教师课表(POST kbxx_teacher_ifr;jsid=教工号,courses 输出的 teacherCode) */
|
|
355
|
+
async fetchTeacherSchedule(cookie, { teacherId, term = '', department = '' } = {}) {
|
|
356
|
+
const body =
|
|
357
|
+
`jsid=${encodeURIComponent(teacherId)}&skyx=${encodeURIComponent(department)}` +
|
|
358
|
+
`&xnxqh=${encodeURIComponent(term)}&xz=`
|
|
359
|
+
const res = await this.request(`${this.baseUrl}/jsxsd/kbcx/kbxx_teacher_ifr`, 'POST', { body, cookie })
|
|
360
|
+
return parseSimpleTable(res.text || '')
|
|
361
|
+
},
|
|
362
|
+
|
|
363
|
+
/** T14 教室课表(POST kbxx_classroom_ifr;campus 用中文名映射,week 默认当前周) */
|
|
364
|
+
async fetchRoomSchedule(cookie, { campusCode, week = '1', building = '', fromSec = '', toSec = '', term = '' } = {}) {
|
|
365
|
+
const body =
|
|
366
|
+
`xnxqh=${encodeURIComponent(term)}&xqid=${encodeURIComponent(campusCode)}` +
|
|
367
|
+
`&jzwid=${encodeURIComponent(building)}&zc1=${encodeURIComponent(week)}&zc2=${encodeURIComponent(week)}` +
|
|
368
|
+
`&jc1=${encodeURIComponent(fromSec)}&jc2=${encodeURIComponent(toSec)}`
|
|
369
|
+
const res = await this.request(`${this.baseUrl}/jsxsd/kbcx/kbxx_classroom_ifr`, 'POST', { body, cookie })
|
|
370
|
+
return parseSimpleTable(res.text || '')
|
|
371
|
+
},
|
|
372
|
+
|
|
373
|
+
/** T14 选课学分统计(POST xs_xkglxftjlist,xnxq 学期) */
|
|
374
|
+
async fetchXkCredits(cookie, { term = '' } = {}) {
|
|
375
|
+
const body = `xnxq=${encodeURIComponent(term)}`
|
|
376
|
+
const res = await this.request(`${this.baseUrl}/jsxsd/xsxk/xs_xkglxftjlist`, 'POST', { body, cookie })
|
|
377
|
+
return parseSimpleTable(res.text || '')
|
|
378
|
+
},
|
|
379
|
+
|
|
380
|
+
/** T14 选退课日志(POST xs_txlist,xnxqh 学期 + xkmc 选课轮次,空=全部) */
|
|
381
|
+
async fetchXkLogs(cookie, { term = '', round = '' } = {}) {
|
|
382
|
+
const body = `xkmc=${encodeURIComponent(round)}&xnxqh=${encodeURIComponent(term)}`
|
|
383
|
+
const res = await this.request(`${this.baseUrl}/jsxsd/xsxk/xs_txlist`, 'POST', { body, cookie })
|
|
384
|
+
return parseSimpleTable(res.text || '')
|
|
385
|
+
},
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* jwc.sdufe.edu.cn 通知公告(公开源,无需登录;T17 双源之一)。
|
|
389
|
+
* 礼貌纪律:只抓请求的页(分页路径 tzgg/2.htm…),不预取整站。
|
|
390
|
+
*/
|
|
391
|
+
async fetchJwcNotices(page = 1) {
|
|
392
|
+
const path = page <= 1 ? '/zxdt/tzgg.htm' : `/zxdt/tzgg/${page - 1}.htm`
|
|
393
|
+
let response
|
|
394
|
+
try {
|
|
395
|
+
response = await env.fetch('https://jwc.sdufe.edu.cn' + path, {
|
|
396
|
+
headers: { 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) linke-cli' },
|
|
397
|
+
redirect: 'follow',
|
|
398
|
+
timeoutMs: 15000,
|
|
399
|
+
})
|
|
400
|
+
} catch (err) {
|
|
401
|
+
throw networkError('抓取教务处公告页', err)
|
|
402
|
+
}
|
|
403
|
+
if (!response.ok) {
|
|
404
|
+
throw networkError(`抓取教务处公告页(HTTP ${response.status})`, null)
|
|
405
|
+
}
|
|
406
|
+
return parseJwcNotices(await response.text())
|
|
407
|
+
},
|
|
408
|
+
|
|
409
|
+
/** 教务系统「已收公告」(个人收件,登录态;T17 双源之二) */
|
|
410
|
+
async fetchJwNotices(cookie, { pageIndex = 1 } = {}) {
|
|
411
|
+
return this.fetchSimplePage(cookie, '/jsxsd/ggly/ysgg_query', { pageIndex })
|
|
412
|
+
},
|
|
413
|
+
|
|
414
|
+
/** 补考报名(bkbm_query;非报名期返回语义空态) */
|
|
415
|
+
async fetchMakeups(cookie) {
|
|
416
|
+
const res = await this.request(`${this.baseUrl}/jsxsd/kscj/bkbm_query`, 'GET', { cookie })
|
|
417
|
+
return parseMakeupsHtml(res.text || '')
|
|
418
|
+
},
|
|
419
|
+
|
|
420
|
+
/** 选课轮次列表(xklc_list 只读:轮次/时间窗口;选课动作是写域不碰) */
|
|
421
|
+
async fetchXklcList(cookie) {
|
|
422
|
+
return this.fetchSimplePage(cookie, '/jsxsd/xsxk/xklc_list')
|
|
423
|
+
},
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* 班级三级联动目录(T17 破译,T14 终判翻案):
|
|
427
|
+
* getZyByAjax?skyx=院系码&sknj=年级 → [{dm,dmmc}] 专业
|
|
428
|
+
* getBjByZyAndAjax?skzy=专业dm&sknj=年级 → [{dm,dmmc}] 班级(传 dm 不传名)
|
|
429
|
+
*/
|
|
430
|
+
async fetchClasses(cookie, { collegeCode = '', grade = '' } = {}) {
|
|
431
|
+
const results = {}
|
|
432
|
+
const zyRes = await this.request(
|
|
433
|
+
`${this.baseUrl}/jsxsd/kbcx/getZyByAjax?skyx=${encodeURIComponent(collegeCode)}&sknj=${encodeURIComponent(grade)}`,
|
|
434
|
+
'GET',
|
|
435
|
+
{ cookie }
|
|
436
|
+
)
|
|
437
|
+
results.majors = safeJson(zyRes.text)
|
|
438
|
+
if (results.majors && results.majors.length > 0) {
|
|
439
|
+
const first = results.majors[0]
|
|
440
|
+
const bjRes = await this.request(
|
|
441
|
+
`${this.baseUrl}/jsxsd/kbcx/getBjByZyAndAjax?skzy=${encodeURIComponent(first.dm)}&sknj=${encodeURIComponent(grade)}`,
|
|
442
|
+
'GET',
|
|
443
|
+
{ cookie }
|
|
444
|
+
)
|
|
445
|
+
results.sampleClasses = safeJson(bjRes.text) // 首专业班级样例
|
|
446
|
+
}
|
|
447
|
+
return results
|
|
448
|
+
},
|
|
449
|
+
|
|
450
|
+
/** 班级课表(skbj=班级 dm 码;sknj 参数实测无效,班级码才是关键过滤) */
|
|
451
|
+
async fetchClassSchedule(cookie, { classCode, term = '' } = {}) {
|
|
452
|
+
const body = `xnxqh=${encodeURIComponent(term)}&sknj=&skyx=&skzy=&skbj=${encodeURIComponent(classCode)}`
|
|
453
|
+
const res = await this.request(`${this.baseUrl}/jsxsd/kbcx/kbxx_xzb_ifr`, 'POST', { body, cookie })
|
|
454
|
+
return parseSimpleTable(res.text || '')
|
|
455
|
+
},
|
|
456
|
+
|
|
457
|
+
/** T14 教学周历(GET jxzl_query,xnxq01id 学期,空=教务默认) */
|
|
458
|
+
async fetchCalendar(cookie, { term = '' } = {}) {
|
|
459
|
+
const q = term ? `?xnxq01id=${encodeURIComponent(term)}` : ''
|
|
460
|
+
const res = await this.request(`${this.baseUrl}/jsxsd/jxzl/jxzl_query${q}`, 'GET', { cookie })
|
|
461
|
+
return parseSimpleTable(res.text || '')
|
|
462
|
+
},
|
|
463
|
+
|
|
464
|
+
async fetchSimplePage(cookie, path, { pageIndex = 1, dropFirst = false, tableIndex = 0 } = {}) {
|
|
465
|
+
const sep = path.includes('?') ? '&' : '?'
|
|
466
|
+
const res = await this.request(`${this.baseUrl}${path}${sep}pageIndex=${pageIndex}`, 'GET', { cookie })
|
|
467
|
+
return parseSimpleTable(res.text || '', { dropFirst, tableIndex })
|
|
468
|
+
},
|
|
469
|
+
|
|
470
|
+
/** session 探活:返回当前登录用户信息;过期抛 isJwLoginExpired 错误 */
|
|
471
|
+
async probeSession(cookie) {
|
|
472
|
+
const html = await this.fetchProfileHtml(cookie)
|
|
473
|
+
if (isJwLoginExpired(html)) {
|
|
474
|
+
const err = new Error('jw login expired')
|
|
475
|
+
err.isJwLoginExpired = true
|
|
476
|
+
throw err
|
|
477
|
+
}
|
|
478
|
+
const userInfo = parseUserData(html)
|
|
479
|
+
if (!hasAuthenticatedProfileMarkers(html, userInfo)) {
|
|
480
|
+
const err = new Error('jw login expired')
|
|
481
|
+
err.isJwLoginExpired = true
|
|
482
|
+
throw err
|
|
483
|
+
}
|
|
484
|
+
return userInfo
|
|
485
|
+
},
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function safeJson(text) {
|
|
490
|
+
try {
|
|
491
|
+
const parsed = JSON.parse(String(text || ''))
|
|
492
|
+
return Array.isArray(parsed) ? parsed : []
|
|
493
|
+
} catch {
|
|
494
|
+
return []
|
|
495
|
+
}
|
|
496
|
+
}
|
package/src/encoding.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 强智教务(Kingosoft,jsxsd 路径)登录密码加密。
|
|
3
|
+
* 与 App 端 services/auth/jwLoginService.js computeEncoded 逐字节同源;
|
|
4
|
+
* 算法:账号%%%密码 的前 20 个字符,每个字符后按 sxh 对应位数字
|
|
5
|
+
* 从 scode 头部取 N 个字符插入;20 字符之后原样拼接。
|
|
6
|
+
*/
|
|
7
|
+
export function computeEncoded(account, password, seedScode, seedSxh) {
|
|
8
|
+
if (!account || !password || !seedScode || !seedSxh) return ''
|
|
9
|
+
let scode = seedScode
|
|
10
|
+
const code = `${account}%%%${password}`
|
|
11
|
+
let encoded = ''
|
|
12
|
+
for (let i = 0; i < code.length; i++) {
|
|
13
|
+
if (i < 20) {
|
|
14
|
+
const n = parseInt(seedSxh.substring(i, i + 1), 10)
|
|
15
|
+
const take = Number.isNaN(n) ? 0 : n
|
|
16
|
+
encoded += code.substring(i, i + 1) + scode.substring(0, take)
|
|
17
|
+
scode = scode.substring(take, scode.length)
|
|
18
|
+
} else {
|
|
19
|
+
encoded += code.substring(i, code.length)
|
|
20
|
+
break
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return encoded
|
|
24
|
+
}
|