linke-cli 0.2.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -3
- package/package.json +1 -1
- package/skills/linke/SKILL.md +3 -2
- package/src/bin.js +94 -24
- package/src/loginserver.js +178 -49
- package/src/schools/sdufe/parsers.js +7 -3
- package/src/web/login.html +200 -18
package/README.md
CHANGED
|
@@ -17,9 +17,12 @@ linke schedule # 直接用,登录全自动
|
|
|
17
17
|
要求 Node.js ≥ 18.14,零运行时依赖。
|
|
18
18
|
|
|
19
19
|
`linke login` 的登录页由本机 CLI 提供(仅绑 127.0.0.1 随机端口,页面
|
|
20
|
-
随 npm
|
|
21
|
-
|
|
22
|
-
|
|
20
|
+
随 npm 包分发可审计);**提交后在网页内完成教务登录验证**——验证中
|
|
21
|
+
动效 → 结果回显(成功显示姓名与教学周,凭据此时才落盘 0600;密码错
|
|
22
|
+
误页内提示并可重试,含教务锁号警示)。尝试上限 3 次、5 分钟超时,
|
|
23
|
+
任一终结即自动关闭服务。`--qr` 模式额外在终端展示局域网 URL 二维码
|
|
24
|
+
(含会话令牌),手机同 Wi-Fi 扫码填写、全程手机端闭环,凭据不经任
|
|
25
|
+
何云端。`linke config` 保留为非 TTY / SSH 场景的终端兜底。
|
|
23
26
|
|
|
24
27
|
## 命令
|
|
25
28
|
|
|
@@ -56,6 +59,19 @@ stdout 只输出业务 JSON;进度与错误走 stderr。exit code 契约见
|
|
|
56
59
|
经 `src/schools/registry.js` 暴露统一接口。新增学校 = 新增
|
|
57
60
|
`src/schools/<id>/` 适配器目录并注册。
|
|
58
61
|
|
|
62
|
+
## 已知问题与排障
|
|
63
|
+
|
|
64
|
+
- **登录页提交报「Failed to fetch」**:提交未送达本机登录服务。
|
|
65
|
+
常见原因是本机代理 / TUN 工具(Clash、Stash、Surge 等)劫持了
|
|
66
|
+
localhost 请求——为 localhost 加直连规则或暂时关闭代理后重试。
|
|
67
|
+
本次提交未送达,不会占用教务尝试次数。
|
|
68
|
+
- **CLI 运行在远程机器(远程桌面 / SSH)**:`linke login` 打印的
|
|
69
|
+
127.0.0.1 地址只在**那台机器**的浏览器可用。远程操作时请在被控
|
|
70
|
+
机器本机打开浏览器;手机填写用 `linke login --qr`(局域网地址)。
|
|
71
|
+
- **怀疑装到旧版本**:若走 npmmirror 等镜像源安装,镜像同步有延迟,
|
|
72
|
+
可能装到旧版。用 `npm ls -g linke-cli` 核对版本,或
|
|
73
|
+
`npm install -g linke-cli --registry https://registry.npmjs.org` 指定官方源。
|
|
74
|
+
|
|
59
75
|
## 开发
|
|
60
76
|
|
|
61
77
|
```bash
|
package/package.json
CHANGED
package/skills/linke/SKILL.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: linke
|
|
3
|
-
version: 0.
|
|
3
|
+
version: 0.3.1
|
|
4
4
|
description: "林课教务查询 CLI(山东财经大学正方教务):查课表、查成绩、看教务登录状态。当用户问「我这学期课表」「今天有什么课」「我的成绩/绩点」「挂了什么课」「教务登录状态」等教务只读信息时使用。登录全自动(验证码云端识别),无需用户介入。"
|
|
5
5
|
metadata:
|
|
6
6
|
requires:
|
|
@@ -21,7 +21,8 @@ linke status
|
|
|
21
21
|
|
|
22
22
|
- `configured: false` → 用户还没配置凭据。**引导用户本人**在终端运行
|
|
23
23
|
`linke login`(本机起登录页并自动打开浏览器,手机填写加 `--qr` 出
|
|
24
|
-
|
|
24
|
+
局域网二维码;验证在网页内完成——通过才保存,失败页内重试,全程
|
|
25
|
+
无需看终端;凭据只存本机 `~/.linke-cli/`)。**你不代输密码、
|
|
25
26
|
不把密码写进任何命令**——凭据只经用户本人的网页/终端进入本机。
|
|
26
27
|
- `configured: true` 即可用,无需关心登录:每个命令内置鉴权状态机,
|
|
27
28
|
session 过期自动重登,只有凭据失效才需要人介入。
|
package/src/bin.js
CHANGED
|
@@ -126,20 +126,95 @@ function openBrowser(url) {
|
|
|
126
126
|
exec(cmd, () => {}) // 打开失败不致命:终端已打印 URL
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
+
function pickPrimaryLanUrl(urls) {
|
|
130
|
+
// 主网卡取舍:192.168.x 优先(家用路由常态),其次 10.x / 172.16-31.x
|
|
131
|
+
const score = (url) => {
|
|
132
|
+
if (url.includes('//192.168.')) return 0
|
|
133
|
+
if (url.includes('//10.')) return 1
|
|
134
|
+
if (/\/\/172\.(1[6-9]|2\d|3[01])\./.test(url)) return 2
|
|
135
|
+
return 3
|
|
136
|
+
}
|
|
137
|
+
return [...urls].sort((a, b) => score(a) - score(b))[0] || null
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* 验证回调工厂(T7 验收 8:复用 session 状态机的登录实现,不另写
|
|
142
|
+
* 登录路径)。凭据仅在验证通过后才落盘(0600 不变式),失败不留
|
|
143
|
+
* 半成品文件。导出供测试注入 fake 适配器验证分类与落盘纪律。
|
|
144
|
+
*/
|
|
145
|
+
export function createVerifyCredentials({ school = DEFAULT_SCHOOL, apiBase = DEFAULT_API_BASE } = {}) {
|
|
146
|
+
return async function verifyCredentials(userId, password) {
|
|
147
|
+
const config = { school, userId, password, apiBase }
|
|
148
|
+
const adapter = getAdapter(school)
|
|
149
|
+
try {
|
|
150
|
+
const session = await forceLogin(adapter, config) // 内部含 saveSession
|
|
151
|
+
saveConfig(config) // 验证通过才落盘
|
|
152
|
+
const info = session.userInfo || {}
|
|
153
|
+
return {
|
|
154
|
+
ok: true,
|
|
155
|
+
summary: {
|
|
156
|
+
name: info.name || '',
|
|
157
|
+
unit: info.unit || '',
|
|
158
|
+
weekNow: info.week ? info.week.now : '',
|
|
159
|
+
weekAll: info.week ? info.week.all : '',
|
|
160
|
+
},
|
|
161
|
+
}
|
|
162
|
+
} catch (err) {
|
|
163
|
+
if (err instanceof LinkeError && err.code === 'CREDENTIAL_INVALID') {
|
|
164
|
+
return { ok: false, kind: 'credential', message: err.message }
|
|
165
|
+
}
|
|
166
|
+
return { ok: false, kind: 'service', message: err.message || String(err) }
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
129
171
|
async function cmdLogin(flags) {
|
|
130
172
|
const qr = flags.qr === true
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
173
|
+
const verifyCredentials = createVerifyCredentials()
|
|
174
|
+
|
|
175
|
+
let terminalOutcome = 'closed'
|
|
176
|
+
const onEvent = (event) => {
|
|
177
|
+
switch (event.type) {
|
|
178
|
+
case 'submitted':
|
|
179
|
+
progress(`收到网页提交(第 ${event.attempts}/3 次尝试),正在验证教务登录...`)
|
|
180
|
+
break
|
|
181
|
+
case 'success': {
|
|
182
|
+
const s = event.summary || {}
|
|
183
|
+
const bits = [s.name, s.weekNow ? `第 ${s.weekNow} 周` : ''].filter(Boolean).join(' · ')
|
|
184
|
+
progress(`验证通过${bits ? `(${bits})` : ''}`)
|
|
185
|
+
progress(`凭据已保存到 ~/.linke-cli/config.json(权限 600),session 已就绪`)
|
|
186
|
+
break
|
|
187
|
+
}
|
|
188
|
+
case 'credential-error':
|
|
189
|
+
progress(`教务返回密码错误${event.remaining > 0 ? `(剩余 ${event.remaining} 次尝试)` : ''}`)
|
|
190
|
+
break
|
|
191
|
+
case 'service-error':
|
|
192
|
+
progress(`验证服务异常:${event.message}${event.remaining > 0 ? `(剩余 ${event.remaining} 次尝试)` : ''}`)
|
|
193
|
+
break
|
|
194
|
+
case 'attempts-exhausted':
|
|
195
|
+
progress('已达尝试上限(3 次),登录服务关闭;请确认凭据后重新运行 linke login')
|
|
196
|
+
break
|
|
197
|
+
case 'timeout':
|
|
198
|
+
progress('超时未完成验证,凭据未保存;可重新运行 linke login')
|
|
199
|
+
break
|
|
200
|
+
default:
|
|
201
|
+
break
|
|
202
|
+
}
|
|
203
|
+
if (event.type === 'success') terminalOutcome = 'success'
|
|
204
|
+
if (event.type === 'timeout') terminalOutcome = 'timeout'
|
|
205
|
+
if (event.type === 'attempts-exhausted') terminalOutcome = 'exhausted'
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const handle = await startLoginServer({ qr, verify: verifyCredentials, onEvent })
|
|
209
|
+
progress('登录页已就绪(提交后网页内完成验证;5 分钟超时,成功或达尝试上限后自动关闭)')
|
|
137
210
|
progress(`本机访问: ${handle.urls.local}`)
|
|
211
|
+
progress('(该地址仅本机浏览器可用;CLI 运行在远程机器上时,请在那台机器的浏览器中打开)')
|
|
138
212
|
if (qr && handle.urls.lan.length > 0) {
|
|
139
|
-
progress('
|
|
140
|
-
for (const lan of handle.urls.lan) {
|
|
141
|
-
|
|
142
|
-
|
|
213
|
+
progress('局域网访问(含会话令牌,勿转发他人):')
|
|
214
|
+
for (const lan of handle.urls.lan) progress(` ${lan}`)
|
|
215
|
+
const primary = pickPrimaryLanUrl(handle.urls.lan)
|
|
216
|
+
if (primary) {
|
|
217
|
+
const { modules } = buildQr(Buffer.from(primary, 'utf8'))
|
|
143
218
|
process.stderr.write('\n' + renderTerminal(modules, 2) + '\n\n')
|
|
144
219
|
}
|
|
145
220
|
} else if (qr) {
|
|
@@ -155,21 +230,16 @@ async function cmdLogin(flags) {
|
|
|
155
230
|
process.exit(130)
|
|
156
231
|
})
|
|
157
232
|
|
|
158
|
-
|
|
159
|
-
if (
|
|
160
|
-
|
|
161
|
-
return
|
|
162
|
-
}
|
|
163
|
-
progress(`凭据已保存(学号 ${result.userId}),正在验证教务登录...`)
|
|
164
|
-
// 复用 verify 逻辑:网页配置后立即做一次真实登录验证。
|
|
165
|
-
// 验证失败不影响已保存的凭据(多为验证码偶发),提示后以非零码退出。
|
|
166
|
-
try {
|
|
167
|
-
return await cmdVerify()
|
|
168
|
-
} catch (err) {
|
|
169
|
-
progress(`凭据已保存,但本次自动验证未通过:${err.message || err}`)
|
|
170
|
-
progress('稍后运行任意查询命令(如 linke scores)会自动重试登录')
|
|
171
|
-
return err instanceof LinkeError ? err.exitCode : 1
|
|
233
|
+
await handle.done
|
|
234
|
+
if (terminalOutcome === 'success') {
|
|
235
|
+
emitJson({ ok: true, outcome: 'success' })
|
|
236
|
+
return 0
|
|
172
237
|
}
|
|
238
|
+
if (terminalOutcome === 'exhausted') return 3 // 对齐 LOGIN_RETRY_EXHAUSTED 契约
|
|
239
|
+
if (terminalOutcome === 'timeout') return 1
|
|
240
|
+
// closed:页面未完成验证即被关闭(如令牌取走失败后的兜底收摊)
|
|
241
|
+
progress('登录服务已结束,凭据未保存;可重新运行 linke login')
|
|
242
|
+
return 1
|
|
173
243
|
}
|
|
174
244
|
|
|
175
245
|
async function cmdVerify() {
|
package/src/loginserver.js
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* 本地登录服务(
|
|
2
|
+
* 本地登录服务(T7:网页内验证闭环):
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
4
|
+
* 流程:页面提交凭据 → 服务立即应答并进入「验证中」→ CLI 侧执行
|
|
5
|
+
* 完整教务登录验证(由 verify 回调注入,复用 session 状态机的登录
|
|
6
|
+
* 实现)→ 页面轮询 GET /result 获取结果 → 成功才由 verify 回调落盘。
|
|
7
|
+
*
|
|
8
|
+
* 令牌语义(T7 验收 3,会话化折中):URL 令牌在会话内有效——
|
|
9
|
+
* 成功 / 超时 / 达尝试上限即失效。尝试上限默认 3 次(保守值:
|
|
10
|
+
* 教务锁定阈值无档案记录,按卡文口径取 3),既防本机令牌爆破,
|
|
11
|
+
* 更防教务系统因连续错密码锁定账号。
|
|
12
|
+
*
|
|
13
|
+
* 安全口径(T6 不回归):Host 必须 IP 字面量(防 DNS rebinding)、
|
|
14
|
+
* Origin 存在须同源(防跨站)、POST/结果内容零日志;页面随包分发。
|
|
14
15
|
*/
|
|
15
16
|
import http from 'node:http'
|
|
16
17
|
import crypto from 'node:crypto'
|
|
@@ -18,18 +19,19 @@ import fs from 'node:fs'
|
|
|
18
19
|
import path from 'node:path'
|
|
19
20
|
import os from 'node:os'
|
|
20
21
|
import { fileURLToPath } from 'node:url'
|
|
21
|
-
import { saveConfig, DEFAULT_SCHOOL, DEFAULT_API_BASE } from './config.js'
|
|
22
|
-
import { clearSession } from './session.js'
|
|
23
22
|
|
|
24
23
|
const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000
|
|
25
24
|
const MAX_BODY_BYTES = 64 * 1024
|
|
25
|
+
const DEFAULT_MAX_ATTEMPTS = 3
|
|
26
|
+
// 终结性结果(成功/上限耗尽)发出后留给页面的收尾窗口
|
|
27
|
+
const RESULT_LINGER_MS = 10 * 1000
|
|
26
28
|
|
|
27
29
|
function loginPageHtml() {
|
|
28
30
|
const here = path.dirname(fileURLToPath(import.meta.url))
|
|
29
31
|
return fs.readFileSync(path.join(here, 'web', 'login.html'), 'utf8')
|
|
30
32
|
}
|
|
31
33
|
|
|
32
|
-
/** 枚举本机局域网 IPv4
|
|
34
|
+
/** 枚举本机局域网 IPv4(虚拟网段在内,展示侧由调用方取舍) */
|
|
33
35
|
export function lanAddresses() {
|
|
34
36
|
const result = []
|
|
35
37
|
const interfaces = os.networkInterfaces()
|
|
@@ -52,16 +54,37 @@ function isIpLiteralHost(host) {
|
|
|
52
54
|
* 起本地登录服务。
|
|
53
55
|
* @param {object} options
|
|
54
56
|
* @param {boolean} options.qr 二维码模式:绑 0.0.0.0(含 127.0.0.1);默认仅 127.0.0.1
|
|
55
|
-
* @param {
|
|
56
|
-
* @param {number} options.
|
|
57
|
-
* @
|
|
57
|
+
* @param {number} options.timeoutMs 超时自动关闭(会话最长时长)
|
|
58
|
+
* @param {number} options.maxAttempts 提交尝试上限(默认 3)
|
|
59
|
+
* @param {(userId: string, password: string) => Promise<{ok: true, summary: object} | {ok: false, kind: 'credential'|'service', message: string}>} options.verify
|
|
60
|
+
* 验证回调:执行完整教务登录验证;成功路径内完成凭据落盘(0600),
|
|
61
|
+
* 失败路径不得写任何凭据文件。kind 区分「凭据错误」(页内重试)
|
|
62
|
+
* 与「网络/识别服务失败」(稍后再试)。
|
|
63
|
+
* @param {(event: {type: string, [k: string]: any}) => void} options.onEvent
|
|
64
|
+
* 终端汇报事件:submitted / success / credential-error / service-error /
|
|
65
|
+
* attempts-exhausted / timeout
|
|
66
|
+
* @returns {Promise<{server, port, token, urls, close: () => void, done: Promise<{type: string}>, attempts: () => number}>}
|
|
58
67
|
*/
|
|
59
|
-
export function startLoginServer({
|
|
68
|
+
export function startLoginServer({
|
|
69
|
+
qr = false,
|
|
70
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
71
|
+
maxAttempts = DEFAULT_MAX_ATTEMPTS,
|
|
72
|
+
verify,
|
|
73
|
+
onEvent = () => {},
|
|
74
|
+
} = {}) {
|
|
60
75
|
return new Promise((resolve, reject) => {
|
|
61
76
|
const token = crypto.randomBytes(16).toString('hex')
|
|
62
77
|
const pageHtml = loginPageHtml()
|
|
63
|
-
let
|
|
78
|
+
let sessionOpen = true // 令牌会话有效性:成功/超时/上限耗尽即 false
|
|
79
|
+
let attempts = 0
|
|
80
|
+
let verifying = false
|
|
81
|
+
let lastResult = null // {type:'success'|'credential'|'service', ...}
|
|
82
|
+
let resultAcked = false
|
|
64
83
|
let closed = false
|
|
84
|
+
let doneResolve
|
|
85
|
+
const done = new Promise((r) => {
|
|
86
|
+
doneResolve = r
|
|
87
|
+
})
|
|
65
88
|
|
|
66
89
|
const server = http.createServer((req, res) => {
|
|
67
90
|
const url = new URL(req.url, 'http://placeholder')
|
|
@@ -70,16 +93,17 @@ export function startLoginServer({ qr = false, timeoutMs = DEFAULT_TIMEOUT_MS, o
|
|
|
70
93
|
res.end(JSON.stringify(payload))
|
|
71
94
|
}
|
|
72
95
|
|
|
73
|
-
// 防 DNS rebinding:Host 必须是 IP 字面量
|
|
74
96
|
if (!isIpLiteralHost(req.headers.host)) {
|
|
75
97
|
sendJson(403, { ok: false, error: '非法 Host' })
|
|
76
98
|
return
|
|
77
99
|
}
|
|
78
100
|
|
|
101
|
+
const tokenOk = () => sessionOpen && url.searchParams.get('t') === token
|
|
102
|
+
|
|
79
103
|
if (req.method === 'GET' && url.pathname === '/') {
|
|
80
|
-
if (
|
|
104
|
+
if (!tokenOk()) {
|
|
81
105
|
res.writeHead(403, { 'Content-Type': 'text/html;charset=utf-8' })
|
|
82
|
-
res.end('<meta charset="utf-8"><p
|
|
106
|
+
res.end('<meta charset="utf-8"><p>链接无效或已失效:请回到终端重新运行 linke login。</p>')
|
|
83
107
|
return
|
|
84
108
|
}
|
|
85
109
|
res.writeHead(200, { 'Content-Type': 'text/html;charset=utf-8' })
|
|
@@ -87,15 +111,67 @@ export function startLoginServer({ qr = false, timeoutMs = DEFAULT_TIMEOUT_MS, o
|
|
|
87
111
|
return
|
|
88
112
|
}
|
|
89
113
|
|
|
114
|
+
if (req.method === 'GET' && url.pathname === '/result') {
|
|
115
|
+
// 结果轮询只校验令牌匹配:成功/上限后令牌会话已结束,但结果
|
|
116
|
+
// 仍须让页面取走(提交路径此时已被 sessionOpen 拒绝,无重放面)
|
|
117
|
+
if (url.searchParams.get('t') !== token) {
|
|
118
|
+
sendJson(403, { ok: false, error: '令牌错误' })
|
|
119
|
+
return
|
|
120
|
+
}
|
|
121
|
+
if (lastResult) {
|
|
122
|
+
const terminal =
|
|
123
|
+
lastResult.type === 'success' ||
|
|
124
|
+
(lastResult.type !== 'success' && lastResult.final)
|
|
125
|
+
const payload = { status: 'done', ...lastResult }
|
|
126
|
+
if (terminal && !resultAcked) {
|
|
127
|
+
resultAcked = true
|
|
128
|
+
// 页面已收到终结性结果:短暂延迟后收摊
|
|
129
|
+
setTimeout(closeServer, 1000)
|
|
130
|
+
}
|
|
131
|
+
sendJson(200, payload)
|
|
132
|
+
return
|
|
133
|
+
}
|
|
134
|
+
sendJson(200, { status: verifying ? 'verifying' : 'idle' })
|
|
135
|
+
return
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (req.method === 'GET' && url.pathname === '/state') {
|
|
139
|
+
// 页面刷新后恢复上下文用(T7 打回验收 11/12):报告已提交次数
|
|
140
|
+
// 与剩余额度,让用户在重复提交前看到教务锁号警示。
|
|
141
|
+
// 与 /result 同口径:终结性结果未被取走前仍可查询。
|
|
142
|
+
if (url.searchParams.get('t') !== token) {
|
|
143
|
+
sendJson(403, { ok: false, error: '令牌错误' })
|
|
144
|
+
return
|
|
145
|
+
}
|
|
146
|
+
sendJson(200, {
|
|
147
|
+
status: lastResult ? 'done' : verifying ? 'verifying' : 'idle',
|
|
148
|
+
attempts,
|
|
149
|
+
remaining: Math.max(0, maxAttempts - attempts),
|
|
150
|
+
maxAttempts,
|
|
151
|
+
result: lastResult,
|
|
152
|
+
})
|
|
153
|
+
return
|
|
154
|
+
}
|
|
155
|
+
|
|
90
156
|
if (req.method === 'POST' && url.pathname === '/submit') {
|
|
91
|
-
// 防 CSRF:浏览器提交必带 Origin,存在则必须同源
|
|
92
157
|
const origin = req.headers.origin
|
|
93
|
-
if (origin) {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
158
|
+
if (origin && origin !== `http://${req.headers.host}`) {
|
|
159
|
+
sendJson(403, { ok: false, error: '非法来源' })
|
|
160
|
+
return
|
|
161
|
+
}
|
|
162
|
+
// 令牌经请求体传递(页面为相对路径提交,URL 无 query),
|
|
163
|
+
// 此处只先校验会话有效性,令牌匹配在解析 body 后校验
|
|
164
|
+
if (!sessionOpen) {
|
|
165
|
+
sendJson(403, { ok: false, error: '会话已结束,请重新运行 linke login' })
|
|
166
|
+
return
|
|
167
|
+
}
|
|
168
|
+
if (verifying) {
|
|
169
|
+
sendJson(409, { ok: false, error: '正在验证上一次提交,请稍候' })
|
|
170
|
+
return
|
|
171
|
+
}
|
|
172
|
+
if (attempts >= maxAttempts) {
|
|
173
|
+
sendJson(429, { ok: false, error: `已达尝试上限(${maxAttempts} 次)` })
|
|
174
|
+
return
|
|
99
175
|
}
|
|
100
176
|
const chunks = []
|
|
101
177
|
let size = 0
|
|
@@ -108,10 +184,6 @@ export function startLoginServer({ qr = false, timeoutMs = DEFAULT_TIMEOUT_MS, o
|
|
|
108
184
|
chunks.push(chunk)
|
|
109
185
|
})
|
|
110
186
|
req.on('end', () => {
|
|
111
|
-
if (tokenUsed) {
|
|
112
|
-
sendJson(403, { ok: false, error: '令牌已使用,请重新运行 linke login' })
|
|
113
|
-
return
|
|
114
|
-
}
|
|
115
187
|
let body
|
|
116
188
|
try {
|
|
117
189
|
body = JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
|
@@ -129,19 +201,62 @@ export function startLoginServer({ qr = false, timeoutMs = DEFAULT_TIMEOUT_MS, o
|
|
|
129
201
|
sendJson(400, { ok: false, error: '学号与密码均不能为空' })
|
|
130
202
|
return
|
|
131
203
|
}
|
|
132
|
-
//
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
})
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
204
|
+
// 计数在任何验证发生前完成:提交即视为一次教务尝试(保守口径)
|
|
205
|
+
attempts += 1
|
|
206
|
+
const remaining = maxAttempts - attempts
|
|
207
|
+
verifying = true
|
|
208
|
+
lastResult = null
|
|
209
|
+
resultAcked = false
|
|
210
|
+
sendJson(200, { ok: true, attempts, remaining })
|
|
211
|
+
onEvent({ type: 'submitted', attempts, remaining, userId })
|
|
212
|
+
// 注意:此处不打印/记录凭据(红线)
|
|
213
|
+
Promise.resolve()
|
|
214
|
+
.then(() => verify(userId, password))
|
|
215
|
+
.then((outcome) => {
|
|
216
|
+
verifying = false
|
|
217
|
+
if (outcome && outcome.ok) {
|
|
218
|
+
sessionOpen = false // 令牌会话结束(验收 3)
|
|
219
|
+
lastResult = {
|
|
220
|
+
type: 'success',
|
|
221
|
+
summary: outcome.summary || {},
|
|
222
|
+
maxAttempts,
|
|
223
|
+
}
|
|
224
|
+
onEvent({ type: 'success', summary: outcome.summary, userId })
|
|
225
|
+
} else {
|
|
226
|
+
const kind = outcome && outcome.kind === 'credential' ? 'credential' : 'service'
|
|
227
|
+
const final = remaining <= 0
|
|
228
|
+
lastResult = {
|
|
229
|
+
type: kind,
|
|
230
|
+
message: (outcome && outcome.message) || '验证失败',
|
|
231
|
+
remaining,
|
|
232
|
+
final,
|
|
233
|
+
maxAttempts,
|
|
234
|
+
}
|
|
235
|
+
onEvent({ type: kind === 'credential' ? 'credential-error' : 'service-error', message: lastResult.message, remaining, final, userId })
|
|
236
|
+
if (final) {
|
|
237
|
+
sessionOpen = false
|
|
238
|
+
scheduleForceClose()
|
|
239
|
+
onEvent({ type: 'attempts-exhausted', attempts })
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
})
|
|
243
|
+
.catch((err) => {
|
|
244
|
+
verifying = false
|
|
245
|
+
const final = remaining <= 0
|
|
246
|
+
lastResult = {
|
|
247
|
+
type: 'service',
|
|
248
|
+
message: (err && err.message) || '验证过程异常',
|
|
249
|
+
remaining,
|
|
250
|
+
final,
|
|
251
|
+
maxAttempts,
|
|
252
|
+
}
|
|
253
|
+
onEvent({ type: 'service-error', message: lastResult.message, remaining, final, userId })
|
|
254
|
+
if (final) {
|
|
255
|
+
sessionOpen = false
|
|
256
|
+
scheduleForceClose()
|
|
257
|
+
onEvent({ type: 'attempts-exhausted', attempts })
|
|
258
|
+
}
|
|
259
|
+
})
|
|
145
260
|
})
|
|
146
261
|
return
|
|
147
262
|
}
|
|
@@ -149,18 +264,30 @@ export function startLoginServer({ qr = false, timeoutMs = DEFAULT_TIMEOUT_MS, o
|
|
|
149
264
|
sendJson(404, { ok: false, error: 'Not Found' })
|
|
150
265
|
})
|
|
151
266
|
|
|
267
|
+
let lingerHandle = null
|
|
268
|
+
let timeoutHandle = null
|
|
269
|
+
|
|
152
270
|
const closeServer = () => {
|
|
153
271
|
if (closed) return
|
|
154
272
|
closed = true
|
|
155
273
|
clearTimeout(timeoutHandle)
|
|
156
|
-
|
|
274
|
+
clearTimeout(lingerHandle)
|
|
157
275
|
if (typeof server.closeAllConnections === 'function') server.closeAllConnections()
|
|
158
276
|
server.close()
|
|
277
|
+
sessionOpen = false
|
|
278
|
+
doneResolve({ type: 'closed' })
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// 终结性失败结果若始终无人来取(页面已关等),超窗强制收摊
|
|
282
|
+
const scheduleForceClose = () => {
|
|
283
|
+
clearTimeout(lingerHandle)
|
|
284
|
+
lingerHandle = setTimeout(closeServer, RESULT_LINGER_MS)
|
|
159
285
|
}
|
|
160
286
|
|
|
161
|
-
|
|
287
|
+
timeoutHandle = setTimeout(() => {
|
|
288
|
+
onEvent({ type: 'timeout' })
|
|
289
|
+
sessionOpen = false
|
|
162
290
|
closeServer()
|
|
163
|
-
onSubmit && onSubmit({ ok: false, error: 'timeout' })
|
|
164
291
|
}, timeoutMs)
|
|
165
292
|
|
|
166
293
|
server.on('error', reject)
|
|
@@ -176,6 +303,8 @@ export function startLoginServer({ qr = false, timeoutMs = DEFAULT_TIMEOUT_MS, o
|
|
|
176
303
|
token,
|
|
177
304
|
urls,
|
|
178
305
|
close: closeServer,
|
|
306
|
+
done,
|
|
307
|
+
attempts: () => attempts,
|
|
179
308
|
})
|
|
180
309
|
})
|
|
181
310
|
})
|
|
@@ -10,23 +10,27 @@
|
|
|
10
10
|
import { stripSpaces, isJwLoginExpired } from '../../util.js'
|
|
11
11
|
import { parseError } from '../../errors.js'
|
|
12
12
|
|
|
13
|
-
/**
|
|
13
|
+
/** 解析个人主页:姓名/单位/专业/班级 + 当前教学周(用于登录确认与 status/成功页展示) */
|
|
14
14
|
export function parseUserData(html) {
|
|
15
15
|
if (!html || typeof html !== 'string') {
|
|
16
|
-
return { name: '', unit: '', discipline: '', class: '' }
|
|
16
|
+
return { name: '', unit: '', discipline: '', class: '', week: null }
|
|
17
17
|
}
|
|
18
18
|
const nameMatch = html.match(/<span class="blue f16 b">(.*?)<\/span>/)
|
|
19
19
|
const name = nameMatch ? nameMatch[1] : ''
|
|
20
20
|
const userMatches = html.matchAll(/middletopdwxxcont">(.*?)<\/div>/g)
|
|
21
21
|
const userData = Array.from(userMatches).map((m) => m[1])
|
|
22
|
+
// 周次(教务主页 xsMain_new.jsp 口径,交互文档 1.6)
|
|
23
|
+
const weekMatch = html.match(/<span class="main_text main_color">第(.*?)周<\/span>\/(.*?)周/)
|
|
24
|
+
const week = weekMatch ? { now: weekMatch[1] || '', all: weekMatch[2] || '' } : null
|
|
22
25
|
if (userData.length < 3) {
|
|
23
|
-
return { name: name || '', unit: '', discipline: '', class: '' }
|
|
26
|
+
return { name: name || '', unit: '', discipline: '', class: '', week }
|
|
24
27
|
}
|
|
25
28
|
return {
|
|
26
29
|
name: name || '',
|
|
27
30
|
unit: userData[0] || '',
|
|
28
31
|
discipline: userData[1] || '',
|
|
29
32
|
class: userData[2] || '',
|
|
33
|
+
week,
|
|
30
34
|
}
|
|
31
35
|
}
|
|
32
36
|
|
package/src/web/login.html
CHANGED
|
@@ -33,9 +33,32 @@
|
|
|
33
33
|
border-radius: 8px; background: #2da44e; color: #fff; cursor: pointer;
|
|
34
34
|
}
|
|
35
35
|
button:disabled { opacity: .6; cursor: default; }
|
|
36
|
-
.msg { margin-top: 16px; font-size: 13px; line-height: 1.7; display: none; }
|
|
37
|
-
.msg.
|
|
38
|
-
.msg.
|
|
36
|
+
.msg { margin-top: 16px; font-size: 13px; line-height: 1.7; display: none; white-space: pre-line; }
|
|
37
|
+
.msg.cred { display: block; color: #cf222e; }
|
|
38
|
+
.msg.svc { display: block; color: #9a6700; }
|
|
39
|
+
/* 验证中 */
|
|
40
|
+
.verifying { display: none; text-align: center; padding: 18px 0 8px; }
|
|
41
|
+
.verifying .spinner {
|
|
42
|
+
width: 34px; height: 34px; margin: 0 auto 14px; border-radius: 50%;
|
|
43
|
+
border: 3px solid #d0d7de; border-top-color: #2da44e;
|
|
44
|
+
animation: spin 0.9s linear infinite;
|
|
45
|
+
}
|
|
46
|
+
@media (prefers-color-scheme: dark) { .verifying .spinner { border-color: #3a4149; border-top-color: #2da44e; } }
|
|
47
|
+
@keyframes spin { to { transform: rotate(360deg); } }
|
|
48
|
+
.verifying p { font-size: 14px; color: #6a737d; line-height: 1.7; }
|
|
49
|
+
@media (prefers-color-scheme: dark) { .verifying p { color: #9aa4af; } }
|
|
50
|
+
/* 成功 */
|
|
51
|
+
.success { display: none; text-align: center; padding: 10px 0 0; }
|
|
52
|
+
.success .mark { font-size: 40px; line-height: 1; }
|
|
53
|
+
.success h2 { font-size: 17px; margin: 10px 0 6px; }
|
|
54
|
+
.success .info { font-size: 14px; color: #6a737d; line-height: 1.8; }
|
|
55
|
+
@media (prefers-color-scheme: dark) { .success .info { color: #9aa4af; } }
|
|
56
|
+
.success .next {
|
|
57
|
+
margin-top: 16px; padding: 12px; border-radius: 8px; background: #f0f6ff;
|
|
58
|
+
font-size: 13px; line-height: 1.8; text-align: left;
|
|
59
|
+
}
|
|
60
|
+
@media (prefers-color-scheme: dark) { .success .next { background: #1b2635; } }
|
|
61
|
+
.success .next code { background: rgba(175,184,193,.25); padding: 1px 6px; border-radius: 4px; }
|
|
39
62
|
</style>
|
|
40
63
|
</head>
|
|
41
64
|
<body>
|
|
@@ -43,14 +66,31 @@
|
|
|
43
66
|
<h1>林课 linke · 教务账号配置</h1>
|
|
44
67
|
<p class="sub">此页面由<strong>你本机的 linke CLI</strong> 提供,密码只保存在本机
|
|
45
68
|
(~/.linke-cli/,权限 600),不会发送到任何服务器。</p>
|
|
69
|
+
|
|
46
70
|
<form id="form">
|
|
47
71
|
<label for="userId">学号</label>
|
|
48
72
|
<input id="userId" name="userId" autocomplete="username" inputmode="numeric" required>
|
|
49
73
|
<label for="password">教务密码</label>
|
|
50
74
|
<input id="password" name="password" type="password" autocomplete="current-password" required>
|
|
51
|
-
<button id="btn" type="submit"
|
|
75
|
+
<button id="btn" type="submit">验证并保存</button>
|
|
52
76
|
</form>
|
|
53
77
|
<p id="msg" class="msg"></p>
|
|
78
|
+
|
|
79
|
+
<div id="verifying" class="verifying">
|
|
80
|
+
<div class="spinner"></div>
|
|
81
|
+
<p id="verifyText">正在验证教务登录…<br>(自动获取并识别验证码,约需数秒,请勿关闭本页)</p>
|
|
82
|
+
</div>
|
|
83
|
+
|
|
84
|
+
<div id="success" class="success">
|
|
85
|
+
<div class="mark">✅</div>
|
|
86
|
+
<h2 id="okTitle">验证成功</h2>
|
|
87
|
+
<p class="info" id="okInfo"></p>
|
|
88
|
+
<div class="next">凭据已保存到本机(权限 600)。<br>
|
|
89
|
+
现在可以直接问你的 AI:<br>
|
|
90
|
+
<code>「我这周有什么课」「我的成绩怎么样」</code><br>
|
|
91
|
+
也可以在终端运行 <code>linke schedule</code> 查看。
|
|
92
|
+
</div>
|
|
93
|
+
</div>
|
|
54
94
|
</main>
|
|
55
95
|
<script>
|
|
56
96
|
const params = new URLSearchParams(location.search)
|
|
@@ -58,37 +98,179 @@
|
|
|
58
98
|
const form = document.getElementById('form')
|
|
59
99
|
const btn = document.getElementById('btn')
|
|
60
100
|
const msg = document.getElementById('msg')
|
|
101
|
+
const verifying = document.getElementById('verifying')
|
|
102
|
+
const success = document.getElementById('success')
|
|
103
|
+
const userIdInput = document.getElementById('userId')
|
|
104
|
+
const passwordInput = document.getElementById('password')
|
|
105
|
+
const PROXY_HINT = '若本机运行 Clash / Stash / Surge 等代理或 TUN 工具,' +
|
|
106
|
+
'可能拦截了 localhost 请求——请为 localhost 加直连规则或暂时关闭后重试'
|
|
107
|
+
let polling = null
|
|
108
|
+
let pollFailures = 0
|
|
109
|
+
let submittedOnce = false
|
|
110
|
+
|
|
111
|
+
function showForm() {
|
|
112
|
+
form.style.display = ''
|
|
113
|
+
verifying.style.display = 'none'
|
|
114
|
+
success.style.display = 'none'
|
|
115
|
+
btn.disabled = false
|
|
116
|
+
btn.textContent = '验证并保存'
|
|
117
|
+
}
|
|
118
|
+
function showVerifying() {
|
|
119
|
+
form.style.display = 'none'
|
|
120
|
+
msg.className = 'msg'
|
|
121
|
+
verifying.style.display = 'block'
|
|
122
|
+
}
|
|
123
|
+
function showSuccess(info) {
|
|
124
|
+
form.style.display = 'none'
|
|
125
|
+
verifying.style.display = 'none'
|
|
126
|
+
msg.className = 'msg'
|
|
127
|
+
success.style.display = 'block'
|
|
128
|
+
document.getElementById('okInfo').textContent = info
|
|
129
|
+
}
|
|
130
|
+
function showBanner(text) {
|
|
131
|
+
msg.className = 'msg svc'
|
|
132
|
+
msg.textContent = text
|
|
133
|
+
}
|
|
134
|
+
function clearBanner() {
|
|
135
|
+
if (!submittedOnce || polling) { msg.className = 'msg' }
|
|
136
|
+
}
|
|
137
|
+
function renderFailResult(data) {
|
|
138
|
+
// 失败结果:回到表单(凭据错误重输 / 服务错误保留输入重发)
|
|
139
|
+
showForm()
|
|
140
|
+
if (data.type === 'credential') {
|
|
141
|
+
msg.className = 'msg cred'
|
|
142
|
+
msg.textContent = '教务返回:' + (data.message || '账号或密码错误') + '。\n' +
|
|
143
|
+
'⚠️ 连续输错密码可能导致教务账号被锁定,请确认后重试' +
|
|
144
|
+
(data.remaining > 0 ? '(剩余 ' + data.remaining + ' 次尝试机会)。' : '。')
|
|
145
|
+
} else {
|
|
146
|
+
msg.className = 'msg svc'
|
|
147
|
+
msg.textContent = (data.message || '网络或验证码识别服务异常') + '。\n' +
|
|
148
|
+
'这通常与账号密码无关,稍后重试即可' +
|
|
149
|
+
(data.remaining > 0 ? '(剩余 ' + data.remaining + ' 次)' : '') + '。'
|
|
150
|
+
}
|
|
151
|
+
if (data.final) {
|
|
152
|
+
btn.disabled = true
|
|
153
|
+
btn.textContent = '已达尝试上限'
|
|
154
|
+
msg.textContent += '\n已达尝试上限(' + data.maxAttempts + ' 次),本服务即将关闭;请回终端重新运行 linke login。'
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function pollResult() {
|
|
159
|
+
try {
|
|
160
|
+
const res = await fetch('/result?t=' + encodeURIComponent(token), {
|
|
161
|
+
headers: { 'Accept': 'application/json' }
|
|
162
|
+
})
|
|
163
|
+
if (res.status === 403) {
|
|
164
|
+
stopPoll()
|
|
165
|
+
submittedOnce = false
|
|
166
|
+
msg.className = 'msg svc'
|
|
167
|
+
msg.textContent = '登录服务已结束(成功、超时或达尝试上限)。\n' +
|
|
168
|
+
'请回终端查看结果;如需重新配置,请在终端重新运行 linke login。'
|
|
169
|
+
showForm()
|
|
170
|
+
btn.disabled = true
|
|
171
|
+
btn.textContent = '会话已结束'
|
|
172
|
+
return
|
|
173
|
+
}
|
|
174
|
+
const data = await res.json()
|
|
175
|
+
pollFailures = 0
|
|
176
|
+
clearBanner()
|
|
177
|
+
if (data.status === 'verifying' || data.status === 'idle') return
|
|
178
|
+
stopPoll()
|
|
179
|
+
submittedOnce = false
|
|
180
|
+
if (data.type === 'success') {
|
|
181
|
+
const s = data.summary || {}
|
|
182
|
+
const lines = []
|
|
183
|
+
if (s.name) lines.push('姓名:' + s.name)
|
|
184
|
+
if (s.unit) lines.push(s.unit)
|
|
185
|
+
if (s.weekNow) lines.push('当前教学周:第 ' + s.weekNow + ' 周' + (s.weekAll ? ' / 共 ' + s.weekAll + ' 周' : ''))
|
|
186
|
+
showSuccess(lines.length ? lines.join('\n') : '教务登录验证通过,凭据已保存。')
|
|
187
|
+
return
|
|
188
|
+
}
|
|
189
|
+
renderFailResult(data)
|
|
190
|
+
} catch {
|
|
191
|
+
// 轮询失败 ≠ 提交失败(打回验收 9):静默重试,持续失败才提示,
|
|
192
|
+
// 且绝不引导重复提交(防占用教务尝试次数)
|
|
193
|
+
pollFailures += 1
|
|
194
|
+
if (pollFailures >= 12) {
|
|
195
|
+
showBanner('与服务联系中断(约 ' + Math.round(pollFailures * 0.8) + ' 秒无响应),仍在自动重试…\n' +
|
|
196
|
+
'⚠️ 请勿关闭本页或在此重复提交(重复提交会占用教务尝试次数,连续输错可能锁号)。\n' +
|
|
197
|
+
'可回终端查看验证是否已完成;若终端也已无响应,重新运行 linke login。')
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
function stopPoll() {
|
|
202
|
+
if (polling) { clearInterval(polling); polling = null }
|
|
203
|
+
}
|
|
204
|
+
|
|
61
205
|
form.addEventListener('submit', async (e) => {
|
|
62
206
|
e.preventDefault()
|
|
63
207
|
msg.className = 'msg'
|
|
64
208
|
btn.disabled = true
|
|
65
|
-
btn.textContent = '
|
|
209
|
+
btn.textContent = '提交中...'
|
|
66
210
|
try {
|
|
67
211
|
const res = await fetch('/submit', {
|
|
68
212
|
method: 'POST',
|
|
69
213
|
headers: { 'Content-Type': 'application/json' },
|
|
70
214
|
body: JSON.stringify({
|
|
71
215
|
token,
|
|
72
|
-
userId:
|
|
73
|
-
password:
|
|
216
|
+
userId: userIdInput.value.trim(),
|
|
217
|
+
password: passwordInput.value
|
|
74
218
|
})
|
|
75
219
|
})
|
|
76
220
|
const data = await res.json()
|
|
77
|
-
if (data.ok)
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
221
|
+
if (!data.ok) throw new Error(data.error || '提交失败')
|
|
222
|
+
submittedOnce = true
|
|
223
|
+
showVerifying()
|
|
224
|
+
passwordInput.value = '' // 提交后即清空,避免长期驻留输入框
|
|
225
|
+
stopPoll()
|
|
226
|
+
pollFailures = 0
|
|
227
|
+
polling = setInterval(pollResult, 800)
|
|
228
|
+
pollResult()
|
|
85
229
|
} catch (err) {
|
|
86
|
-
|
|
87
|
-
msg.
|
|
230
|
+
// 提交 POST 失败(与轮询失败分开归因——打回验收 9)
|
|
231
|
+
msg.className = 'msg svc'
|
|
232
|
+
msg.textContent = '提交失败:无法连接本机登录服务(' + (err.message || '网络异常') + ')。\n' +
|
|
233
|
+
'本次提交未送达,不会占用教务尝试次数,可稍后重试。\n' + PROXY_HINT + '。'
|
|
88
234
|
btn.disabled = false
|
|
89
|
-
btn.textContent = '
|
|
235
|
+
btn.textContent = '验证并保存'
|
|
90
236
|
}
|
|
91
237
|
})
|
|
238
|
+
|
|
239
|
+
// 页面(重)加载时恢复上下文(打回验收 11/12):已提交过则给出
|
|
240
|
+
// 防重复警示,验证中则直接恢复验证态,避免用户无感重复提交
|
|
241
|
+
;(async function restoreState() {
|
|
242
|
+
try {
|
|
243
|
+
const res = await fetch('/state?t=' + encodeURIComponent(token))
|
|
244
|
+
if (res.status === 403) return // 会话结束,保持初始表单(提交会被拒)
|
|
245
|
+
const state = await res.json()
|
|
246
|
+
if (state.attempts > 0) {
|
|
247
|
+
msg.className = 'msg svc'
|
|
248
|
+
msg.textContent = '本会话已提交 ' + state.attempts + ' 次(上限 ' +
|
|
249
|
+
state.maxAttempts + ')。为避免教务账号因连续错密码被锁定,' +
|
|
250
|
+
'请确认凭据后再提交(剩余 ' + state.remaining + ' 次)。'
|
|
251
|
+
}
|
|
252
|
+
if (state.status === 'verifying') {
|
|
253
|
+
showVerifying()
|
|
254
|
+
submittedOnce = true
|
|
255
|
+
stopPoll()
|
|
256
|
+
polling = setInterval(pollResult, 800)
|
|
257
|
+
pollResult()
|
|
258
|
+
} else if (state.status === 'done' && state.result) {
|
|
259
|
+
submittedOnce = false
|
|
260
|
+
if (state.result.type === 'success') {
|
|
261
|
+
const s = state.result.summary || {}
|
|
262
|
+
const lines = []
|
|
263
|
+
if (s.name) lines.push('姓名:' + s.name)
|
|
264
|
+
if (s.weekNow) lines.push('当前教学周:第 ' + s.weekNow + ' 周')
|
|
265
|
+
showSuccess(lines.length ? lines.join('\n') : '教务登录验证通过,凭据已保存。')
|
|
266
|
+
} else {
|
|
267
|
+
renderFailResult(state.result)
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
} catch {
|
|
271
|
+
/* /state 获取失败不阻塞初始表单 */
|
|
272
|
+
}
|
|
273
|
+
})()
|
|
92
274
|
</script>
|
|
93
275
|
</body>
|
|
94
276
|
</html>
|