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
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 学校适配器注册表。核心命令框架只依赖本文件暴露的接口,
|
|
3
|
+
* 不 import 任何学校特有模块——新增学校 = 新增 schools/<id>/ 目录
|
|
4
|
+
* 并在此注册(T3 验收 7:多学校扩展地基)。
|
|
5
|
+
*/
|
|
6
|
+
import { sdufeAdapter } from './sdufe/adapter.js'
|
|
7
|
+
|
|
8
|
+
const adapters = new Map([[sdufeAdapter.id, sdufeAdapter]])
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* 适配器接口契约(核心框架只调用这些成员):
|
|
12
|
+
* id, name, baseUrl
|
|
13
|
+
* login({ userId, password, recognizeCaptcha }, { maxRetries, onProgress })
|
|
14
|
+
* → { cookie, userInfo }
|
|
15
|
+
* probeSession(cookie) → userInfo(过期抛 err.isJwLoginExpired)
|
|
16
|
+
* fetchCurrentTerm(cookie) → string | null
|
|
17
|
+
* fetchSchedule(cookie, { term, week }) → { weeks, remark? }
|
|
18
|
+
* fetchScores(cookie, { term }) → rows[]
|
|
19
|
+
*/
|
|
20
|
+
export function getAdapter(schoolId) {
|
|
21
|
+
const adapter = adapters.get(schoolId)
|
|
22
|
+
if (!adapter) {
|
|
23
|
+
throw new Error(
|
|
24
|
+
`未知的学校适配器: ${schoolId}(当前支持: ${Array.from(adapters.keys()).join(', ')})`
|
|
25
|
+
)
|
|
26
|
+
}
|
|
27
|
+
return adapter
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** 注册适配器(新学校接入入口;测试也用它注入 fake 适配器) */
|
|
31
|
+
export function registerAdapter(adapter) {
|
|
32
|
+
if (!adapter || !adapter.id) throw new Error('适配器缺少 id')
|
|
33
|
+
adapters.set(adapter.id, adapter)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function listAdapters() {
|
|
37
|
+
return Array.from(adapters.values()).map((a) => ({ id: a.id, name: a.name, baseUrl: a.baseUrl }))
|
|
38
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
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
|
+
import { extractCookieHeader, isJwLoginExpired, progress } from '../../util.js'
|
|
11
|
+
import { networkError, credentialInvalid, loginRetryExhausted, LinkeError, EXIT } from '../../errors.js'
|
|
12
|
+
import { computeEncoded } from './encoding.js'
|
|
13
|
+
import {
|
|
14
|
+
parseUserData,
|
|
15
|
+
hasAuthenticatedProfileMarkers,
|
|
16
|
+
parseCurrentTerm,
|
|
17
|
+
parseScheduleHtml,
|
|
18
|
+
parseScoresHtml,
|
|
19
|
+
} from './parsers.js'
|
|
20
|
+
|
|
21
|
+
const USER_AGENT = 'Apifox/1.0.0 (https://apifox.com)'
|
|
22
|
+
const REQUEST_TIMEOUT_MS = 20000
|
|
23
|
+
|
|
24
|
+
export const sdufeAdapter = {
|
|
25
|
+
id: 'sdufe',
|
|
26
|
+
name: '山东财经大学(正方教务 jsxsd)',
|
|
27
|
+
baseUrl: 'http://jw.sdufe.edu.cn',
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 教务 HTTP 请求(cookie 手动管理,redirect 跟随——与 uni.request 默认行为一致)。
|
|
31
|
+
* 返回 { status, text, arrayBuffer }。
|
|
32
|
+
*/
|
|
33
|
+
async request(url, method, { body, cookie, expect = 'text' } = {}) {
|
|
34
|
+
let response
|
|
35
|
+
try {
|
|
36
|
+
response = await fetch(url, {
|
|
37
|
+
method,
|
|
38
|
+
redirect: 'follow',
|
|
39
|
+
headers: {
|
|
40
|
+
'User-Agent': USER_AGENT,
|
|
41
|
+
Accept: '*/*',
|
|
42
|
+
Connection: 'keep-alive',
|
|
43
|
+
...(cookie ? { Cookie: cookie } : {}),
|
|
44
|
+
...(body !== undefined ? { 'Content-Type': 'application/x-www-form-urlencoded' } : {}),
|
|
45
|
+
},
|
|
46
|
+
body,
|
|
47
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
48
|
+
})
|
|
49
|
+
} catch (err) {
|
|
50
|
+
throw networkError('请求教务系统', err)
|
|
51
|
+
}
|
|
52
|
+
const nextCookie = extractCookieHeader(response)
|
|
53
|
+
if (expect === 'buffer') {
|
|
54
|
+
return { status: response.status, buffer: Buffer.from(await response.arrayBuffer()), nextCookie }
|
|
55
|
+
}
|
|
56
|
+
return { status: response.status, text: await response.text(), nextCookie }
|
|
57
|
+
},
|
|
58
|
+
|
|
59
|
+
/** 获取会话种子(scode/sxh)与初始 Cookie */
|
|
60
|
+
async fetchSeed() {
|
|
61
|
+
const res = await this.request(`${this.baseUrl}/Logon.do?method=logon&flag=sess`, 'POST')
|
|
62
|
+
const parts = String(res.text || '').trim().split('#')
|
|
63
|
+
if (parts.length < 2 || !res.nextCookie) {
|
|
64
|
+
throw new LinkeError('SEED_FAILED', '获取教务会话种子失败', {
|
|
65
|
+
exitCode: EXIT.NETWORK,
|
|
66
|
+
hint: '教务系统可能暂不可达,稍后重试',
|
|
67
|
+
})
|
|
68
|
+
}
|
|
69
|
+
return { seedScode: parts[0] || '', seedSxh: parts[1] || '', cookie: res.nextCookie }
|
|
70
|
+
},
|
|
71
|
+
|
|
72
|
+
/** 获取验证码图片 base64 */
|
|
73
|
+
async fetchCaptcha(cookie) {
|
|
74
|
+
const res = await this.request(`${this.baseUrl}/verifycode.servlet`, 'GET', {
|
|
75
|
+
cookie,
|
|
76
|
+
expect: 'buffer',
|
|
77
|
+
})
|
|
78
|
+
const textPeek = res.buffer.subarray(0, 200).toString('utf8')
|
|
79
|
+
if (textPeek.includes('</html>')) {
|
|
80
|
+
throw new LinkeError('CAPTCHA_FAILED', '获取验证码图片失败(教务返回了页面而非图片)', {
|
|
81
|
+
exitCode: EXIT.NETWORK,
|
|
82
|
+
hint: '稍后重试;若持续出现,教务可能正在维护',
|
|
83
|
+
})
|
|
84
|
+
}
|
|
85
|
+
return res.buffer.toString('base64')
|
|
86
|
+
},
|
|
87
|
+
|
|
88
|
+
/** 提交登录。验证码错误 → isCaptchaError;密码错误 → isPasswordError */
|
|
89
|
+
async submitLogin({ userId, password, captcha, cookie, seedScode, seedSxh }) {
|
|
90
|
+
const encoded = computeEncoded(userId, password, seedScode, seedSxh)
|
|
91
|
+
if (!encoded) throw new Error('登录参数计算失败')
|
|
92
|
+
const form = [
|
|
93
|
+
['userAccount', userId],
|
|
94
|
+
['userPassword', password],
|
|
95
|
+
['RANDOMCODE', captcha],
|
|
96
|
+
['encoded', encoded],
|
|
97
|
+
]
|
|
98
|
+
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
|
|
99
|
+
.join('&')
|
|
100
|
+
const res = await this.request(`${this.baseUrl}/Logon.do?method=logon`, 'POST', {
|
|
101
|
+
body: form,
|
|
102
|
+
cookie,
|
|
103
|
+
})
|
|
104
|
+
const text = res.text || ''
|
|
105
|
+
if (text.includes('验证码错误')) {
|
|
106
|
+
const err = new Error('验证码错误')
|
|
107
|
+
err.isCaptchaError = true
|
|
108
|
+
throw err
|
|
109
|
+
}
|
|
110
|
+
if (text.includes('密码错误')) {
|
|
111
|
+
const err = new Error('账号或密码错误')
|
|
112
|
+
err.isPasswordError = true
|
|
113
|
+
throw err
|
|
114
|
+
}
|
|
115
|
+
return res
|
|
116
|
+
},
|
|
117
|
+
|
|
118
|
+
/** 获取个人主页 HTML(登录确认 / session 探活) */
|
|
119
|
+
async fetchProfileHtml(cookie) {
|
|
120
|
+
const res = await this.request(`${this.baseUrl}/jsxsd/framework/xsMain_new.jsp`, 'GET', { cookie })
|
|
121
|
+
return res.text || ''
|
|
122
|
+
},
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* 完整登录:种子 → 验证码 → 云端识别 → 提交 → 主页确认。
|
|
126
|
+
* 验证码识别/验证码错误自动重试(整体重新取种子+验证码,maxRetries 次)。
|
|
127
|
+
* @returns {{ cookie: string, userInfo: object }}
|
|
128
|
+
*/
|
|
129
|
+
async login({ userId, password, recognizeCaptcha }, { maxRetries = 3, onProgress = progress } = {}) {
|
|
130
|
+
let lastCaptchaError = null
|
|
131
|
+
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
132
|
+
onProgress(`获取教务会话(第 ${attempt}/${maxRetries} 次)...`)
|
|
133
|
+
const { seedScode, seedSxh, cookie } = await this.fetchSeed()
|
|
134
|
+
try {
|
|
135
|
+
onProgress('获取验证码图片...')
|
|
136
|
+
const captchaBase64 = await this.fetchCaptcha(cookie)
|
|
137
|
+
onProgress('云端识别验证码...')
|
|
138
|
+
const captcha = await recognizeCaptcha(captchaBase64)
|
|
139
|
+
if (!captcha) throw Object.assign(new Error('识别结果为空'), { isCaptchaError: true })
|
|
140
|
+
|
|
141
|
+
onProgress('提交登录...')
|
|
142
|
+
try {
|
|
143
|
+
await this.submitLogin({ userId, password, captcha, cookie, seedScode, seedSxh })
|
|
144
|
+
} catch (err) {
|
|
145
|
+
if (err.isCaptchaError) throw err
|
|
146
|
+
if (err.isPasswordError) throw credentialInvalid('教务返回密码错误')
|
|
147
|
+
throw err
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
onProgress('确认登录状态...')
|
|
151
|
+
const html = await this.fetchProfileHtml(cookie)
|
|
152
|
+
// 假登录检测(App 1.0.6/1.0.8 修复口径)
|
|
153
|
+
const isLoginPage =
|
|
154
|
+
typeof html === 'string' &&
|
|
155
|
+
html.includes('RANDOMCODE') &&
|
|
156
|
+
(html.includes('userAccount') || html.includes('userPassword'))
|
|
157
|
+
const userInfo = parseUserData(html)
|
|
158
|
+
if (isLoginPage || isJwLoginExpired(html) || !hasAuthenticatedProfileMarkers(html, userInfo)) {
|
|
159
|
+
throw Object.assign(new Error('假登录:主页未命中已登录特征'), { isCaptchaError: true })
|
|
160
|
+
}
|
|
161
|
+
return { cookie, userInfo }
|
|
162
|
+
} catch (err) {
|
|
163
|
+
if (err.isCaptchaError && attempt < maxRetries) {
|
|
164
|
+
lastCaptchaError = err
|
|
165
|
+
onProgress('验证码未通过,刷新重试...')
|
|
166
|
+
continue
|
|
167
|
+
}
|
|
168
|
+
if (err.isCaptchaError) {
|
|
169
|
+
lastCaptchaError = err
|
|
170
|
+
break
|
|
171
|
+
}
|
|
172
|
+
throw err
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
throw loginRetryExhausted(maxRetries)
|
|
176
|
+
},
|
|
177
|
+
|
|
178
|
+
/** 解析当前学期(课表页 select),失败返回 null */
|
|
179
|
+
async fetchCurrentTerm(cookie) {
|
|
180
|
+
const res = await this.request(`${this.baseUrl}/jsxsd/xskb/xskb_list.do`, 'GET', { cookie })
|
|
181
|
+
return parseCurrentTerm(res.text || '')
|
|
182
|
+
},
|
|
183
|
+
|
|
184
|
+
/** 抓课表:term 空 = 教务默认学期;week 空 = 全部周 */
|
|
185
|
+
async fetchSchedule(cookie, { term = '', week = '' } = {}) {
|
|
186
|
+
const form = `xnxq01id=${encodeURIComponent(term)}&zc=${encodeURIComponent(String(week))}`
|
|
187
|
+
const res = await this.request(`${this.baseUrl}/jsxsd/xskb/xskb_list.do`, 'POST', {
|
|
188
|
+
body: form,
|
|
189
|
+
cookie,
|
|
190
|
+
})
|
|
191
|
+
return parseScheduleHtml(res.text || '')
|
|
192
|
+
},
|
|
193
|
+
|
|
194
|
+
/** 抓成绩:term 空 = 全部学期 */
|
|
195
|
+
async fetchScores(cookie, { term = '' } = {}) {
|
|
196
|
+
const form = `kksj=${encodeURIComponent(term)}&xsfs=all`
|
|
197
|
+
const res = await this.request(`${this.baseUrl}/jsxsd/kscj/cjcx_list`, 'POST', {
|
|
198
|
+
body: form,
|
|
199
|
+
cookie,
|
|
200
|
+
})
|
|
201
|
+
return parseScoresHtml(res.text || '')
|
|
202
|
+
},
|
|
203
|
+
|
|
204
|
+
/** session 探活:返回当前登录用户信息;过期抛 isJwLoginExpired 错误 */
|
|
205
|
+
async probeSession(cookie) {
|
|
206
|
+
const html = await this.fetchProfileHtml(cookie)
|
|
207
|
+
if (isJwLoginExpired(html)) {
|
|
208
|
+
const err = new Error('jw login expired')
|
|
209
|
+
err.isJwLoginExpired = true
|
|
210
|
+
throw err
|
|
211
|
+
}
|
|
212
|
+
const userInfo = parseUserData(html)
|
|
213
|
+
if (!hasAuthenticatedProfileMarkers(html, userInfo)) {
|
|
214
|
+
const err = new Error('jw login expired')
|
|
215
|
+
err.isJwLoginExpired = true
|
|
216
|
+
throw err
|
|
217
|
+
}
|
|
218
|
+
return userInfo
|
|
219
|
+
},
|
|
220
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 正方教务(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
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 山财正方教务(jsxsd)页面解析器。
|
|
3
|
+
* 正则全部移植自现役实现,脱节时以仓库真实代码为准回灌:
|
|
4
|
+
* - 课表:linke_PHP/Api/src/app/Model/UserSchedule.php getSchedule()
|
|
5
|
+
* + linke_App/utils/scheduleLoader.js(周循环抓取版)
|
|
6
|
+
* - 成绩:linke_PHP/Api/src/app/Model/UserScore.php reloadUserScoreRows()
|
|
7
|
+
* - 主页:linke_App/services/auth/jwLoginService.js parseUserData()
|
|
8
|
+
* - 学期:linke_App/utils/scheduleLoader.js fetchScheduleTerm()
|
|
9
|
+
*/
|
|
10
|
+
import { stripSpaces, isJwLoginExpired } from '../../util.js'
|
|
11
|
+
import { parseError } from '../../errors.js'
|
|
12
|
+
|
|
13
|
+
/** 解析个人主页:姓名/单位/专业/班级(用于登录确认与 status 展示) */
|
|
14
|
+
export function parseUserData(html) {
|
|
15
|
+
if (!html || typeof html !== 'string') {
|
|
16
|
+
return { name: '', unit: '', discipline: '', class: '' }
|
|
17
|
+
}
|
|
18
|
+
const nameMatch = html.match(/<span class="blue f16 b">(.*?)<\/span>/)
|
|
19
|
+
const name = nameMatch ? nameMatch[1] : ''
|
|
20
|
+
const userMatches = html.matchAll(/middletopdwxxcont">(.*?)<\/div>/g)
|
|
21
|
+
const userData = Array.from(userMatches).map((m) => m[1])
|
|
22
|
+
if (userData.length < 3) {
|
|
23
|
+
return { name: name || '', unit: '', discipline: '', class: '' }
|
|
24
|
+
}
|
|
25
|
+
return {
|
|
26
|
+
name: name || '',
|
|
27
|
+
unit: userData[0] || '',
|
|
28
|
+
discipline: userData[1] || '',
|
|
29
|
+
class: userData[2] || '',
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** 主页 HTML 是否具备已登录特征(防"假登录成功",1.0.6/1.0.8 修复口径) */
|
|
34
|
+
export function hasAuthenticatedProfileMarkers(html, userInfo) {
|
|
35
|
+
if (typeof html !== 'string') return false
|
|
36
|
+
if (html.indexOf('middletopdwxxcont') !== -1) return true
|
|
37
|
+
if (html.indexOf('blue f16 b') !== -1) return true
|
|
38
|
+
if (html.indexOf('main_text main_color') !== -1) return true
|
|
39
|
+
return !!(userInfo && (userInfo.name || userInfo.unit || userInfo.discipline || userInfo.class))
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** 从课表页 select 中解析当前学期(形如 2025-2026-1),失败返回 null */
|
|
43
|
+
export function parseCurrentTerm(html) {
|
|
44
|
+
if (!html || typeof html !== 'string') return null
|
|
45
|
+
const optionRegex = /<option\s+value="(\d{4}-\d{4}-\d)"(?:\s+selected="selected")?>(.*?)<\/option>/g
|
|
46
|
+
const matches = Array.from(html.matchAll(optionRegex))
|
|
47
|
+
if (matches.length === 0) return null
|
|
48
|
+
for (const match of matches) {
|
|
49
|
+
if (match[0].includes('selected="selected"')) return match[1]
|
|
50
|
+
}
|
|
51
|
+
return matches[0][1]
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* 解析课表页 HTML → { weeks: [[cell x7] xN], remark?: string[] }
|
|
56
|
+
* 单元格 { course, teacher, time, location };空格为全空字段。
|
|
57
|
+
*/
|
|
58
|
+
export function parseScheduleHtml(html) {
|
|
59
|
+
if (isJwLoginExpired(html)) {
|
|
60
|
+
const err = new Error('jw login expired')
|
|
61
|
+
err.isJwLoginExpired = true
|
|
62
|
+
throw err
|
|
63
|
+
}
|
|
64
|
+
const cells = Array.from(html.matchAll(/kbcontent"\s?>(.*?)<\/div>/g)).map((m) => m[1])
|
|
65
|
+
if (cells.length < 35) {
|
|
66
|
+
throw parseError('课表(课程格不足 35,页面可能未正常返回)')
|
|
67
|
+
}
|
|
68
|
+
const parsed = cells.map((cell) => {
|
|
69
|
+
if (cell === ' ') return { course: '', teacher: '', time: '', location: '' }
|
|
70
|
+
const courseMatch = cell.match(/(.*?)<font title='老师'>/)
|
|
71
|
+
const teacherMatch = cell.match(/<font title='老师'>(.*?)<\/font>/)
|
|
72
|
+
const timeMatch = cell.match(/<font title='周次.*?'>(.*?)<\/font>/)
|
|
73
|
+
const locationMatch = cell.match(/<font title='教室'>(.*?)<\/font>/)
|
|
74
|
+
return {
|
|
75
|
+
course: courseMatch ? courseMatch[1] : '',
|
|
76
|
+
teacher: teacherMatch ? teacherMatch[1] : '',
|
|
77
|
+
time: timeMatch ? timeMatch[1] : '',
|
|
78
|
+
location: locationMatch ? locationMatch[1] : '',
|
|
79
|
+
}
|
|
80
|
+
})
|
|
81
|
+
const remarks = Array.from(
|
|
82
|
+
html.matchAll(/<\/th>.?<td.?colspan="7".?align="left">(.*?)<\/td>/g)
|
|
83
|
+
).map((m) => m[1])
|
|
84
|
+
const weeks = []
|
|
85
|
+
for (let i = 0; i < parsed.length; i += 7) {
|
|
86
|
+
weeks.push(parsed.slice(i, i + 7))
|
|
87
|
+
}
|
|
88
|
+
const result = { weeks }
|
|
89
|
+
if (remarks.length > 0) result.remark = remarks
|
|
90
|
+
return result
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const INVALID_SCORE_TEXTS = new Set(['-', '--', '---', '—', '暂无', '暂未录入', '未录入', '未公布', '无'])
|
|
94
|
+
const TERM_RE = /^\d{4}-\d{4}-\d$/
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* 解析成绩页 HTML → 行数组 [{ term, courseCode, courseName, scoreText, score, nature }]
|
|
98
|
+
* 口径与 PHP reloadUserScoreRows 一致:数值成绩限 0-100 记入 score,
|
|
99
|
+
* 等级制成绩保留 scoreText、score 为 null;无效占位文本丢弃。
|
|
100
|
+
* courseName 取成绩行内紧邻成绩的列(现役页面为课程名,列序变化时可能为空,
|
|
101
|
+
* 以 courseCode 为准)。
|
|
102
|
+
*/
|
|
103
|
+
export function parseScoresHtml(html) {
|
|
104
|
+
if (isJwLoginExpired(html)) {
|
|
105
|
+
const err = new Error('jw login expired')
|
|
106
|
+
err.isJwLoginExpired = true
|
|
107
|
+
throw err
|
|
108
|
+
}
|
|
109
|
+
const cleaned = stripSpaces(html)
|
|
110
|
+
const rows = []
|
|
111
|
+
const addRow = (term, courseCode, courseName, scoreText, nature) => {
|
|
112
|
+
term = String(term ?? '').trim()
|
|
113
|
+
courseCode = String(courseCode ?? '').trim()
|
|
114
|
+
scoreText = String(scoreText ?? '').trim()
|
|
115
|
+
if (!TERM_RE.test(term) || courseCode === '' || scoreText === '') return
|
|
116
|
+
if (INVALID_SCORE_TEXTS.has(scoreText)) return
|
|
117
|
+
let score = null
|
|
118
|
+
if (/^\d+(\.\d+)?$/.test(scoreText)) {
|
|
119
|
+
const numeric = Number(scoreText)
|
|
120
|
+
if (numeric < 0 || numeric > 100) return
|
|
121
|
+
score = Math.trunc(numeric)
|
|
122
|
+
}
|
|
123
|
+
rows.push({
|
|
124
|
+
term,
|
|
125
|
+
courseCode,
|
|
126
|
+
courseName: String(courseName ?? '').trim(),
|
|
127
|
+
scoreText,
|
|
128
|
+
score,
|
|
129
|
+
nature: String(nature ?? '').trim(),
|
|
130
|
+
})
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// 变体一:行首带学期列(现役主口径,PHP matchesWithLeading)
|
|
134
|
+
const withLeading = Array.from(
|
|
135
|
+
cleaned.matchAll(
|
|
136
|
+
/<tr><td>.*?<\/td><td>(.*?)<\/td><tdalign=.*?>(.*?)<\/td><tdalign=.*?>(.*?)<\/td><!--控制成绩显示--><tdstyle=.*?><ahref=.*?>(.*?)<\/a><\/td><\/td><td>.*?<\/td><!--控制绩点显示--><td>.*?<\/td><td>.*?<\/td><td>(.*?)<\/td><td>.*?<\/td><td>.*?<\/td><\/tr>/g
|
|
137
|
+
)
|
|
138
|
+
)
|
|
139
|
+
for (const m of withLeading) {
|
|
140
|
+
const col1 = m[1] ?? ''
|
|
141
|
+
const col2 = m[2] ?? ''
|
|
142
|
+
if (TERM_RE.test(col1)) {
|
|
143
|
+
addRow(col1, col2, m[3], m[4], m[5])
|
|
144
|
+
} else if (TERM_RE.test(col2)) {
|
|
145
|
+
addRow(col2, col1, m[3], m[4], m[5])
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// 变体二:legacy 无前导学期列(PHP matchesLegacy,仅在变体一整页零命中时启用)
|
|
150
|
+
if (rows.length === 0) {
|
|
151
|
+
const legacy = Array.from(
|
|
152
|
+
cleaned.matchAll(
|
|
153
|
+
/<tdalign=.*?>(.*?)<\/td><tdalign=.*?>(.*?)<\/td><!--控制成绩显示--><tdstyle=.*?><ahref=.*?>(.*?)<\/a><\/td><\/td><td>.*?<\/td><!--控制绩点显示--><td>.*?<\/td><td>.*?<\/td><td>(.*?)<\/td><td>.*?<\/td><td>.*?<\/td>/g
|
|
154
|
+
)
|
|
155
|
+
)
|
|
156
|
+
for (const m of legacy) {
|
|
157
|
+
addRow(m[2], m[1], '', m[3], m[4])
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (rows.length === 0) {
|
|
162
|
+
throw parseError('成绩(整页未命中任何成绩行)')
|
|
163
|
+
}
|
|
164
|
+
return rows
|
|
165
|
+
}
|
package/src/session.js
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 鉴权状态机(T3 验收 3):内建于每个数据命令,调用方无需知道「先登录」。
|
|
3
|
+
*
|
|
4
|
+
* session 有效 → 直接用
|
|
5
|
+
* session 缺失/过期 → 自动重登(云端验证码识别)
|
|
6
|
+
* 凭据失效(教务报密码错误)→ 报错让人介入(exit 2,提示 linke config)
|
|
7
|
+
* 业务请求中发现 session 失效 → 重登一次后重试
|
|
8
|
+
*
|
|
9
|
+
* session 落 ~/.linke-cli/session.json(0600,同凭据目录 0700):
|
|
10
|
+
* { cookie, userInfo, savedAt, expiresAt }
|
|
11
|
+
* 过期口径:教务 JSESSIONID 为不活动过期(实测约 30 分钟量级),
|
|
12
|
+
* 保守取 20 分钟无活动即重登;每次成功请求滑动续期。
|
|
13
|
+
*/
|
|
14
|
+
import fs from 'node:fs'
|
|
15
|
+
import { sessionPath, configDir } from './config.js'
|
|
16
|
+
import { recognizeCaptcha } from './cloudOcr.js'
|
|
17
|
+
import { getAdapter } from './schools/registry.js'
|
|
18
|
+
import { progress } from './util.js'
|
|
19
|
+
|
|
20
|
+
const SESSION_TTL_MS = 20 * 60 * 1000
|
|
21
|
+
const RETRY_ON_EXPIRED_ONCE = 1
|
|
22
|
+
|
|
23
|
+
export function loadSession() {
|
|
24
|
+
try {
|
|
25
|
+
const raw = fs.readFileSync(sessionPath(), 'utf8')
|
|
26
|
+
const parsed = JSON.parse(raw)
|
|
27
|
+
if (!parsed || typeof parsed !== 'object' || !parsed.cookie) return null
|
|
28
|
+
return parsed
|
|
29
|
+
} catch {
|
|
30
|
+
return null
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function saveSession(adapter, cookie, userInfo) {
|
|
35
|
+
fs.mkdirSync(configDir(), { recursive: true })
|
|
36
|
+
const payload = {
|
|
37
|
+
school: adapter.id,
|
|
38
|
+
cookie,
|
|
39
|
+
userInfo: userInfo || {},
|
|
40
|
+
savedAt: new Date().toISOString(),
|
|
41
|
+
expiresAt: Date.now() + SESSION_TTL_MS,
|
|
42
|
+
}
|
|
43
|
+
fs.writeFileSync(sessionPath(), JSON.stringify(payload, null, 2) + '\n', { mode: 0o600 })
|
|
44
|
+
try {
|
|
45
|
+
fs.chmodSync(sessionPath(), 0o600)
|
|
46
|
+
} catch {
|
|
47
|
+
/* 非 POSIX 文件系统时忽略 */
|
|
48
|
+
}
|
|
49
|
+
return payload
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function touchSession(session) {
|
|
53
|
+
if (!session) return
|
|
54
|
+
session.expiresAt = Date.now() + SESSION_TTL_MS
|
|
55
|
+
try {
|
|
56
|
+
fs.writeFileSync(sessionPath(), JSON.stringify(session, null, 2) + '\n', { mode: 0o600 })
|
|
57
|
+
} catch {
|
|
58
|
+
/* 续期失败不影响本次请求 */
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function clearSession() {
|
|
63
|
+
const file = sessionPath()
|
|
64
|
+
if (fs.existsSync(file)) fs.rmSync(file)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function isSessionUsable(session) {
|
|
68
|
+
return !!(session && session.cookie && Date.now() < session.expiresAt - 60_000)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** 强制登录(忽略现有 session),成功后落盘 */
|
|
72
|
+
export async function login(adapter, config) {
|
|
73
|
+
const recognize = (imageBase64) => recognizeCaptcha(config.apiBase, imageBase64)
|
|
74
|
+
const { cookie, userInfo } = await adapter.login(
|
|
75
|
+
{ userId: config.userId, password: config.password, recognizeCaptcha: recognize },
|
|
76
|
+
{ onProgress: progress }
|
|
77
|
+
)
|
|
78
|
+
return saveSession(adapter, cookie, userInfo)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* 状态机主入口:保证 fn 在有效 session 下执行。
|
|
83
|
+
* @param {object} config resolveConfig() 的结果
|
|
84
|
+
* @param {(adapter, session) => Promise<any>} fn 业务函数
|
|
85
|
+
*/
|
|
86
|
+
export async function withSession(config, fn) {
|
|
87
|
+
const adapter = getAdapter(config.school)
|
|
88
|
+
let session = loadSession()
|
|
89
|
+
if (!isSessionUsable(session)) {
|
|
90
|
+
progress('教务 session 缺失或已过期,自动登录...')
|
|
91
|
+
session = await login(adapter, config)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
let expiredRetries = RETRY_ON_EXPIRED_ONCE
|
|
95
|
+
while (true) {
|
|
96
|
+
try {
|
|
97
|
+
const result = await fn(adapter, session)
|
|
98
|
+
touchSession(session)
|
|
99
|
+
return result
|
|
100
|
+
} catch (err) {
|
|
101
|
+
if (err && err.isJwLoginExpired && expiredRetries > 0) {
|
|
102
|
+
expiredRetries -= 1
|
|
103
|
+
progress('教务 session 中途失效,自动重登后重试...')
|
|
104
|
+
session = await login(adapter, config)
|
|
105
|
+
continue
|
|
106
|
+
}
|
|
107
|
+
throw err
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** status 命令用:带本地判定与远端探活的完整状态 */
|
|
113
|
+
export async function inspectSession(config) {
|
|
114
|
+
const adapter = getAdapter(config.school)
|
|
115
|
+
const session = loadSession()
|
|
116
|
+
const local = {
|
|
117
|
+
hasSession: !!session,
|
|
118
|
+
expiresAt: session ? new Date(session.expiresAt).toISOString() : null,
|
|
119
|
+
expired: session ? !isSessionUsable(session) : null,
|
|
120
|
+
userInfo: session?.userInfo || null,
|
|
121
|
+
}
|
|
122
|
+
let remote = null
|
|
123
|
+
if (session && isSessionUsable(session)) {
|
|
124
|
+
try {
|
|
125
|
+
const userInfo = await adapter.probeSession(session.cookie)
|
|
126
|
+
remote = { alive: true, userInfo }
|
|
127
|
+
touchSession(session)
|
|
128
|
+
} catch (err) {
|
|
129
|
+
if (err && err.isJwLoginExpired) {
|
|
130
|
+
remote = { alive: false }
|
|
131
|
+
} else {
|
|
132
|
+
remote = { alive: null, error: err.message }
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return { local, remote }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export { SESSION_TTL_MS }
|
package/src/skill.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* skill 说明书分发(T3 验收 5):把随包分发的 SKILL.md 安装进
|
|
3
|
+
* 用户 agent 的 skills 目录。skill 保持极薄——只描述如何 shell 调用
|
|
4
|
+
* CLI,复杂度全部在 CLI 内(lark-cli 架构口径)。
|
|
5
|
+
*/
|
|
6
|
+
import fs from 'node:fs'
|
|
7
|
+
import os from 'node:os'
|
|
8
|
+
import path from 'node:path'
|
|
9
|
+
import { fileURLToPath } from 'node:url'
|
|
10
|
+
|
|
11
|
+
const SKILL_DIR_NAME = 'linke'
|
|
12
|
+
|
|
13
|
+
function packagedSkillDir() {
|
|
14
|
+
const here = path.dirname(fileURLToPath(import.meta.url))
|
|
15
|
+
// src/ → 包根;npm 安装后 skills/ 随 files 字段一起分发
|
|
16
|
+
return path.join(path.dirname(here), 'skills', SKILL_DIR_NAME)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** 常见 agent 的 skills 根目录(存在才视为目标;--path 可显式指定) */
|
|
20
|
+
function detectSkillRoots() {
|
|
21
|
+
const home = os.homedir()
|
|
22
|
+
return [
|
|
23
|
+
path.join(home, '.agents', 'skills'),
|
|
24
|
+
path.join(home, '.claude', 'skills'),
|
|
25
|
+
path.join(home, '.zcode', 'skills'),
|
|
26
|
+
].filter((dir) => fs.existsSync(path.dirname(dir)))
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function installSkill(explicitPath) {
|
|
30
|
+
const source = packagedSkillDir()
|
|
31
|
+
if (!fs.existsSync(path.join(source, 'SKILL.md'))) {
|
|
32
|
+
throw new Error(`随包 skill 说明书缺失:${source}/SKILL.md(安装包不完整)`)
|
|
33
|
+
}
|
|
34
|
+
const targets = explicitPath ? [explicitPath] : detectSkillRoots()
|
|
35
|
+
if (targets.length === 0) {
|
|
36
|
+
throw new Error(
|
|
37
|
+
'未探测到已安装 agent 的 skills 目录;用 --path <目录> 显式指定,' +
|
|
38
|
+
'例如: linke skill install --path ~/.agents/skills'
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
const installed = []
|
|
42
|
+
for (const root of targets) {
|
|
43
|
+
const dest = path.join(root, SKILL_DIR_NAME)
|
|
44
|
+
fs.mkdirSync(dest, { recursive: true })
|
|
45
|
+
for (const file of fs.readdirSync(source)) {
|
|
46
|
+
fs.copyFileSync(path.join(source, file), path.join(dest, file))
|
|
47
|
+
}
|
|
48
|
+
installed.push(dest)
|
|
49
|
+
}
|
|
50
|
+
return installed
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function skillSourceDir() {
|
|
54
|
+
return packagedSkillDir()
|
|
55
|
+
}
|
package/src/util.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 通用工具:cookie 提取、教务 HTML 清洗、输出辅助。
|
|
3
|
+
* 口径与 App 端 linke_App/repositories/jwHttp.js、utils/jwLoginExpired.js 保持同源。
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* 从 fetch Response 的 Set-Cookie 头提取 "k=v; k2=v2" 形式的 Cookie 头。
|
|
8
|
+
* 依赖 Node >= 18.14 的 headers.getSetCookie()。
|
|
9
|
+
*/
|
|
10
|
+
export function extractCookieHeader(response) {
|
|
11
|
+
const list = response.headers.getSetCookie ? response.headers.getSetCookie() : []
|
|
12
|
+
const pairs = []
|
|
13
|
+
for (const raw of list) {
|
|
14
|
+
const pair = String(raw).split(';')[0]
|
|
15
|
+
if (pair) pairs.push(pair.trim())
|
|
16
|
+
}
|
|
17
|
+
return pairs.join('; ')
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* 教务 HTML 预处理:删除空格(含全角)与换行。
|
|
22
|
+
* PHP 端与 App 端解析前都做同一处理,正则依赖此口径(<td align= 会变 <tdalign=)。
|
|
23
|
+
*/
|
|
24
|
+
export function stripSpaces(html) {
|
|
25
|
+
return String(html ?? '').replace(/[ \u3000\t\n\r]/g, '')
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 教务「登录过期」判定:与 App 端 utils/jwLoginExpired.js 同源。
|
|
30
|
+
* 登录页通常短且含 </html>;部分情况直接返回"用户登录/请先登录"文案。
|
|
31
|
+
*/
|
|
32
|
+
export function isJwLoginExpired(html) {
|
|
33
|
+
if (!html || typeof html !== 'string') return false
|
|
34
|
+
const trimmed = html.trim()
|
|
35
|
+
if (trimmed.includes('</html>') && trimmed.length < 5000) return true
|
|
36
|
+
if (trimmed.includes('用户登录') || trimmed.includes('请先登录')) return true
|
|
37
|
+
return false
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** 进度信息走 stderr,保证 stdout 只有结构化 JSON(agent 解析契约) */
|
|
41
|
+
export function progress(msg) {
|
|
42
|
+
process.stderr.write(`[linke] ${msg}\n`)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** stdout 输出 JSON 结果 */
|
|
46
|
+
export function emitJson(value) {
|
|
47
|
+
process.stdout.write(JSON.stringify(value, null, 2) + '\n')
|
|
48
|
+
}
|