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/src/parsers.js ADDED
@@ -0,0 +1,825 @@
1
+ /**
2
+ * 山财强智教务(Kingosoft)页面解析器。
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: '', week: null }
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
+ // 周次(教务主页 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
25
+ if (userData.length < 3) {
26
+ return { name: name || '', unit: '', discipline: '', class: '', week }
27
+ }
28
+ return {
29
+ name: name || '',
30
+ unit: userData[0] || '',
31
+ discipline: userData[1] || '',
32
+ class: userData[2] || '',
33
+ week,
34
+ }
35
+ }
36
+
37
+ /** 主页 HTML 是否具备已登录特征(防"假登录成功",1.0.6/1.0.8 修复口径) */
38
+ export function hasAuthenticatedProfileMarkers(html, userInfo) {
39
+ if (typeof html !== 'string') return false
40
+ if (html.indexOf('middletopdwxxcont') !== -1) return true
41
+ if (html.indexOf('blue f16 b') !== -1) return true
42
+ if (html.indexOf('main_text main_color') !== -1) return true
43
+ return !!(userInfo && (userInfo.name || userInfo.unit || userInfo.discipline || userInfo.class))
44
+ }
45
+
46
+ /** 从课表页 select 中解析当前学期(形如 2025-2026-1),失败返回 null */
47
+ export function parseCurrentTerm(html) {
48
+ if (!html || typeof html !== 'string') return null
49
+ const optionRegex = /<option\s+value="(\d{4}-\d{4}-\d)"(?:\s+selected="selected")?>(.*?)<\/option>/g
50
+ const matches = Array.from(html.matchAll(optionRegex))
51
+ if (matches.length === 0) return null
52
+ for (const match of matches) {
53
+ if (match[0].includes('selected="selected"')) return match[1]
54
+ }
55
+ return matches[0][1]
56
+ }
57
+
58
+ /**
59
+ * 解析课表页 HTML → { weeks: [[cell x7] xN], remark?: string[] }
60
+ * 单元格 { course, teacher, time, location };空格为全空字段。
61
+ */
62
+ export function parseScheduleHtml(html) {
63
+ if (isJwLoginExpired(html)) {
64
+ const err = new Error('jw login expired')
65
+ err.isJwLoginExpired = true
66
+ throw err
67
+ }
68
+ const cells = Array.from(html.matchAll(/kbcontent"\s?>(.*?)<\/div>/g)).map((m) => m[1])
69
+ if (cells.length < 35) {
70
+ throw parseError('课表(课程格不足 35,页面可能未正常返回)')
71
+ }
72
+ const parsed = cells.map((cell) => {
73
+ if (cell === '&nbsp;') return { course: '', teacher: '', time: '', location: '' }
74
+ const courseMatch = cell.match(/(.*?)<font title='老师'>/)
75
+ const teacherMatch = cell.match(/<font title='老师'>(.*?)<\/font>/)
76
+ const timeMatch = cell.match(/<font title='周次.*?'>(.*?)<\/font>/)
77
+ const locationMatch = cell.match(/<font title='教室'>(.*?)<\/font>/)
78
+ return {
79
+ course: courseMatch ? courseMatch[1] : '',
80
+ teacher: teacherMatch ? teacherMatch[1] : '',
81
+ time: timeMatch ? timeMatch[1] : '',
82
+ location: locationMatch ? locationMatch[1] : '',
83
+ }
84
+ })
85
+ const remarks = Array.from(
86
+ html.matchAll(/<\/th>.?<td.?colspan="7".?align="left">(.*?)<\/td>/g)
87
+ ).map((m) => m[1])
88
+ const weeks = []
89
+ for (let i = 0; i < parsed.length; i += 7) {
90
+ weeks.push(parsed.slice(i, i + 7))
91
+ }
92
+ const result = { weeks }
93
+ if (remarks.length > 0) result.remark = remarks
94
+ return result
95
+ }
96
+
97
+ const INVALID_SCORE_TEXTS = new Set(['-', '--', '---', '—', '暂无', '暂未录入', '未录入', '未公布', '无'])
98
+ const TERM_RE = /^\d{4}-\d{4}-\d$/
99
+
100
+ /** 单元格清洗:剥标签、&nbsp; 转空格、实体解码、收紧空白 */
101
+ function cleanCell(raw) {
102
+ return String(raw ?? '')
103
+ .replace(/&nbsp;/gi, ' ')
104
+ .replace(/<[^>]+>/g, '')
105
+ .replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"')
106
+ .replace(/[\u00a0\u3000]/g, ' ')
107
+ .replace(/\s+/g, ' ')
108
+ .trim()
109
+ }
110
+
111
+ /**
112
+ * 按 th 表头解析一张 HTML 表 → { headers, rows }。
113
+ * rows 为与表头等长的字符串数组(缺失列补空串,多余列丢弃),
114
+ * 列序以 th 顺序为准——教务加列/换列位时解析仍对位。
115
+ */
116
+ function parseTableByHeader(tableHtml) {
117
+ const headers = Array.from(tableHtml.matchAll(/<th\b[^>]*>([\s\S]*?)<\/th>/gi))
118
+ .map((m) => cleanCell(m[1]))
119
+ const rows = []
120
+ for (const m of tableHtml.matchAll(/<tr\b[^>]*>([\s\S]*?)<\/tr>/gi)) {
121
+ const tr = m[1]
122
+ if (/<th\b/i.test(tr)) continue // 表头行
123
+ const cells = Array.from(tr.matchAll(/<td\b[^>]*>([\s\S]*?)<\/td>/gi)).map((c) => cleanCell(c[1]))
124
+ if (cells.length === 0) continue
125
+ rows.push(headers.map((_, i) => cells[i] ?? ''))
126
+ }
127
+ return { headers, rows }
128
+ }
129
+
130
+ /** 在整页里按表头特征找表(返回 { headers, rows } 或 null);导出供测试 */
131
+ export function findTableByHeaders(html, mustInclude) {
132
+ for (const m of html.matchAll(/<table\b[\s\S]*?<\/table>/gi)) {
133
+ const parsed = parseTableByHeader(m[0])
134
+ if (parsed.headers.length === 0) continue
135
+ if (mustInclude.every((h) => parsed.headers.some((x) => x.includes(h)))) {
136
+ return parsed
137
+ }
138
+ }
139
+ return null
140
+ }
141
+
142
+ /**
143
+ * 解析成绩页 HTML → 行数组 [{ term, courseCode, courseName, credit, scoreText, score, nature }]
144
+ * 口径与 PHP reloadUserScoreRows 一致:数值成绩限 0-100 记入 score,
145
+ * 等级制成绩保留 scoreText、score 为 null;无效占位文本丢弃。
146
+ * T10:补捕获学分列(真实页列序 序号/学期/编号/名称/成绩/学分/绩点/
147
+ * 考试性质/课程性质/课程属性/辅修——学分紧跟成绩双闭合单元格后)。
148
+ */
149
+ export function parseScoresHtml(html) {
150
+ if (isJwLoginExpired(html)) {
151
+ const err = new Error('jw login expired')
152
+ err.isJwLoginExpired = true
153
+ throw err
154
+ }
155
+ const cleaned = stripSpaces(html)
156
+ const rows = []
157
+ const addRow = (term, courseCode, courseName, credit, scoreText, nature) => {
158
+ term = String(term ?? '').trim()
159
+ courseCode = String(courseCode ?? '').trim()
160
+ scoreText = String(scoreText ?? '').trim()
161
+ if (!TERM_RE.test(term) || courseCode === '' || scoreText === '') return
162
+ if (INVALID_SCORE_TEXTS.has(scoreText)) return
163
+ let score = null
164
+ if (/^\d+(\.\d+)?$/.test(scoreText)) {
165
+ const numeric = Number(scoreText)
166
+ if (numeric < 0 || numeric > 100) return
167
+ score = Math.trunc(numeric)
168
+ }
169
+ rows.push({
170
+ term,
171
+ courseCode,
172
+ courseName: String(courseName ?? '').trim(),
173
+ credit: String(credit ?? '').trim(),
174
+ scoreText,
175
+ score,
176
+ nature: String(nature ?? '').trim(),
177
+ })
178
+ }
179
+
180
+ // 变体一:行首带序号列,第二列学期(现役主口径,PHP matchesWithLeading)
181
+ const withLeading = Array.from(
182
+ cleaned.matchAll(
183
+ /<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
184
+ )
185
+ )
186
+ for (const m of withLeading) {
187
+ const col1 = m[1] ?? ''
188
+ const col2 = m[2] ?? ''
189
+ if (TERM_RE.test(col1)) {
190
+ addRow(col1, col2, m[3], m[5], m[4], m[6])
191
+ } else if (TERM_RE.test(col2)) {
192
+ addRow(col2, col1, m[3], m[5], m[4], m[6])
193
+ }
194
+ }
195
+
196
+ // 变体二:legacy 无前导学期列(PHP matchesLegacy,仅在变体一整页零命中时启用)
197
+ if (rows.length === 0) {
198
+ const legacy = Array.from(
199
+ cleaned.matchAll(
200
+ /<tdalign=.*?>(.*?)<\/td><tdalign=.*?>(.*?)<\/td><!--控制成绩显示--><tdstyle=.*?><ahref=.*?>(.*?)<\/a><\/td><\/td><td>(.*?)<\/td><!--控制绩点显示--><td>.*?<\/td><td>.*?<\/td><td>(.*?)<\/td><td>.*?<\/td><td>.*?<\/td>/g
201
+ )
202
+ )
203
+ for (const m of legacy) {
204
+ addRow(m[2], m[1], '', m[4], m[3], m[5])
205
+ }
206
+ }
207
+
208
+ if (rows.length === 0) {
209
+ throw parseError('成绩(整页未命中任何成绩行)')
210
+ }
211
+ return rows
212
+ }
213
+
214
+ /**
215
+ * 解析学分修读页(/jsxsd/xxwcqk/xstxkxdqk.do,GET 直出)。
216
+ * 真实结构双表:
217
+ * 汇总表 th=类别/要求学分(大于等于)/已修学分/正在修读
218
+ * 明细表 th=课程编号/课程名称/学分/总成绩/通选课类别
219
+ * → { categories: [{category, required, earned, inProgress}],
220
+ * courses: [{courseCode, courseName, credit, score, type}] }
221
+ */
222
+ export function parseCreditsHtml(html) {
223
+ if (isJwLoginExpired(html)) {
224
+ const err = new Error('jw login expired')
225
+ err.isJwLoginExpired = true
226
+ throw err
227
+ }
228
+ const summary = findTableByHeaders(html, ['类别', '要求学分'])
229
+ const detail = findTableByHeaders(html, ['课程编号', '总成绩'])
230
+ if (!summary && !detail) {
231
+ throw parseError('学分修读(未找到类别统计表或课程明细表)')
232
+ }
233
+ const categories = []
234
+ if (summary) {
235
+ const col = (h) => summary.headers.findIndex((x) => x.includes(h))
236
+ const iCat = col('类别')
237
+ const iReq = col('要求学分')
238
+ const iEarn = col('已修学分')
239
+ const iProg = col('正在修读')
240
+ for (const row of summary.rows) {
241
+ const category = row[iCat] ?? ''
242
+ if (!category) continue
243
+ categories.push({
244
+ category,
245
+ required: row[iReq] ?? '',
246
+ earned: row[iEarn] ?? '',
247
+ inProgress: row[iProg] ?? '',
248
+ })
249
+ }
250
+ }
251
+ const courses = []
252
+ if (detail) {
253
+ const col = (h) => detail.headers.findIndex((x) => x.includes(h))
254
+ const iCode = col('课程编号')
255
+ const iName = col('课程名称')
256
+ const iCredit = col('学分')
257
+ const iScore = col('总成绩')
258
+ const iType = col('通选课类别')
259
+ for (const row of detail.rows) {
260
+ const courseCode = row[iCode] ?? ''
261
+ if (!courseCode) continue
262
+ courses.push({
263
+ courseCode,
264
+ courseName: row[iName] ?? '',
265
+ credit: row[iCredit] ?? '',
266
+ score: row[iScore] ?? '',
267
+ type: iType >= 0 ? row[iType] ?? '' : '',
268
+ })
269
+ }
270
+ }
271
+ return { categories, courses }
272
+ }
273
+
274
+ /** kbxx_kc_ifr 课程网格表头 → 输出字段名(按表头名映射,列序变化不受影响) */
275
+ const COURSE_GRID_FIELDS = [
276
+ ['校区', 'campus'],
277
+ ['上课学院', 'department'],
278
+ ['上课班级', 'className'],
279
+ ['课程编号', 'courseCode'],
280
+ ['课程名称', 'courseName'],
281
+ ['上课周次', 'weeks'],
282
+ ['上课时间', 'time'],
283
+ ['上课地点', 'location'],
284
+ ['授课教师', 'teacher'],
285
+ ['教工号', 'teacherCode'],
286
+ ['课程性质', 'nature'],
287
+ ['学分', 'credit'],
288
+ ['上课人数', 'capacity'],
289
+ ]
290
+
291
+ /**
292
+ * 解析课程课表查询网格(POST /jsxsd/kbcx/kbxx_kc_ifr)。
293
+ * 真实结构单表 13 列(表头见 COURSE_GRID_FIELDS),579+ 数据行整齐 13 td,
294
+ * 单元格 &nbsp; 包裹;另存在整行空行。
295
+ * → { total, courses: [{campus, department, className, courseCode,
296
+ * courseName, weeks, time, location, teacher, teacherCode,
297
+ * nature, credit, capacity?}] }(capacity 仅在页面含该列时输出)
298
+ */
299
+ export function parseCoursesHtml(html) {
300
+ if (isJwLoginExpired(html)) {
301
+ const err = new Error('jw login expired')
302
+ err.isJwLoginExpired = true
303
+ throw err
304
+ }
305
+ const table = findTableByHeaders(html, ['课程编号', '课程名称', '授课教师'])
306
+ if (!table) {
307
+ throw parseError('课程课表(未找到课程数据网格)')
308
+ }
309
+ const colMap = []
310
+ for (const [headerName, field] of COURSE_GRID_FIELDS) {
311
+ const idx = table.headers.findIndex((x) => x.includes(headerName))
312
+ if (idx >= 0) colMap.push([idx, field])
313
+ }
314
+ const courses = []
315
+ for (const row of table.rows) {
316
+ const item = {}
317
+ let courseCode = ''
318
+ for (const [idx, field] of colMap) {
319
+ const value = row[idx] ?? ''
320
+ item[field] = value
321
+ if (field === 'courseCode') courseCode = value
322
+ }
323
+ if (!courseCode) continue // 空行/表头重复行
324
+ courses.push(item)
325
+ }
326
+ return { total: courses.length, courses }
327
+ }
328
+
329
+ /**
330
+ * 解析平均学分绩点页(/jsxsd/kscj/cjcx_avg,GET 直出)。
331
+ * 真实结构单表 10 列:学号/姓名/专业名称/班级名称/培养层次/所修总学分/
332
+ * 课程门数/平均分/平均学分绩/平均学分绩点;一行主修 + N 行辅修
333
+ * (辅修行以专业名称含「(辅修)」判定,行序不保证主修在前)。
334
+ * → { rows: [{ studentId, name, major, className, level, totalCredits,
335
+ * courseCount, averageScore, averageGrade, gpa, majorType }] }
336
+ */
337
+ export function parseGpaHtml(html) {
338
+ if (isJwLoginExpired(html)) {
339
+ const err = new Error('jw login expired')
340
+ err.isJwLoginExpired = true
341
+ throw err
342
+ }
343
+ const table = findTableByHeaders(html, ['学号', '平均学分绩点'])
344
+ if (!table) {
345
+ throw parseError('平均学分绩点(未找到数据表)')
346
+ }
347
+ const col = (h) => table.headers.findIndex((x) => x.includes(h))
348
+ const idx = {
349
+ studentId: col('学号'),
350
+ name: col('姓名'),
351
+ major: col('专业名称'),
352
+ className: col('班级名称'),
353
+ level: col('培养层次'),
354
+ totalCredits: col('所修总学分'),
355
+ courseCount: col('课程门数'),
356
+ averageScore: col('平均分'),
357
+ averageGrade: col('平均学分绩'),
358
+ gpa: col('平均学分绩点'),
359
+ }
360
+ const rows = []
361
+ for (const row of table.rows) {
362
+ const item = {}
363
+ for (const [key, i] of Object.entries(idx)) {
364
+ item[key] = i >= 0 ? row[i] ?? '' : ''
365
+ }
366
+ if (!item.name && !item.major) continue
367
+ item.majorType = /(辅修)|\(辅修\)/.test(item.major) ? '辅修' : '主修'
368
+ rows.push(item)
369
+ }
370
+ if (rows.length === 0) {
371
+ throw parseError('平均学分绩点(数据表零行)')
372
+ }
373
+ return { rows }
374
+ }
375
+
376
+ /**
377
+ * 解析学籍卡片页(/jsxsd/grxx/xsxx,GET 直出,不规则标签值网格)。
378
+ *
379
+ * 敏感裁剪口径(T12 决策,devlog 在案):默认只输出核心学籍字段
380
+ * (院系/专业/学制/班级/学号/层次/年级);--full 额外输出白名单内的
381
+ * 非敏感学籍字段。身份证编号/出生日期/电话/考号/证书号/家庭住址/
382
+ * 家庭成员等强敏感字段不进入任何输出(查询 CLI 的必要范围之外,
383
+ * 避免身份信息流入 agent 上下文与终端日志)。
384
+ *
385
+ * → { studentId, department, major, duration, className, level, grade,
386
+ * extra?: {[label]: value} }
387
+ */
388
+ const XJ_EXTRA_WHITELIST = new Set([
389
+ '性别', '民族', '政治面貌', '学习形式', '学习层次', '外语种类',
390
+ '专业方向', '姓名拼音', '入学日期', '毕业日期', '入党团时间', '籍贯',
391
+ ])
392
+ const XJ_SENSITIVE_RE = /身份证|出生|电话|手机|考号|证书|住址|邮政|联系人|家庭成员|火车站|婚否/
393
+
394
+ export function parseXjHtml(html, { full = false } = {}) {
395
+ if (isJwLoginExpired(html)) {
396
+ const err = new Error('jw login expired')
397
+ err.isJwLoginExpired = true
398
+ throw err
399
+ }
400
+ // 收集全部单元格文本(保留空串——空值是模式 B 的值占位,
401
+ // 跳过会让「外语种类」(空)误吸下一个标签格的文本)
402
+ const texts = []
403
+ for (const m of html.matchAll(/<td\b[^>]*>([\s\S]*?)<\/td>/gi)) {
404
+ texts.push(cleanCell(m[1]))
405
+ }
406
+ const fields = {}
407
+ // 模式 A:一格内「标签:值」连写(真实页:院系:… /专业:… /学制:… /班级:… /学号:…)
408
+ for (const text of texts) {
409
+ if (!text) continue
410
+ const m = text.match(/^([^::]{2,8})[::](.+)$/)
411
+ if (m && !text.includes(' ')) {
412
+ fields[m[1].trim()] = m[2].trim()
413
+ }
414
+ }
415
+ // 模式 B:白名单标签独立成格,值在相邻格(相邻格是另一个标签 → 值为空)
416
+ const LABEL_VALUE_RE = /^[^::]{2,8}[::].+$/
417
+ for (let i = 0; i < texts.length; i++) {
418
+ const label = texts[i]
419
+ if (!XJ_EXTRA_WHITELIST.has(label) || label in fields) continue
420
+ const next = texts[i + 1] ?? ''
421
+ if (XJ_EXTRA_WHITELIST.has(next) || LABEL_VALUE_RE.test(next)) continue
422
+ fields[label] = next
423
+ }
424
+ const studentId = fields['学号'] || ''
425
+ if (!studentId) {
426
+ throw parseError('学籍卡片(未找到学号字段)')
427
+ }
428
+ const className = fields['班级'] || ''
429
+ const gradeMatch = className.match(/(20\d{2})/)
430
+ const result = {
431
+ studentId,
432
+ department: fields['院系'] || '',
433
+ major: fields['专业'] || '',
434
+ duration: fields['学制'] || '',
435
+ className,
436
+ level: fields['学习层次'] || '',
437
+ grade: gradeMatch ? gradeMatch[1] : '',
438
+ }
439
+ if (full) {
440
+ const coreKeys = new Set(['学号', '院系', '专业', '学制', '班级', '学习层次'])
441
+ result.extra = {}
442
+ for (const [label, value] of Object.entries(fields)) {
443
+ if (coreKeys.has(label)) continue
444
+ if (XJ_SENSITIVE_RE.test(label)) continue // 强敏感:--full 也不出
445
+ if (XJ_EXTRA_WHITELIST.has(label) || value.length <= 20) {
446
+ result.extra[label] = value
447
+ }
448
+ }
449
+ }
450
+ return result
451
+ }
452
+
453
+ /**
454
+ * 解析培养执行计划页(/jsxsd/pyfa/pyfa_query,GET 直出)。
455
+ * 真实结构单表 11 列:序号/开课学期/课程编号/课程名称/开课单位/学分/
456
+ * 总学时/考核方式/课程性质/是否考试/课程大纲。
457
+ * → { total, courses: [{ term, courseCode, courseName, department,
458
+ * credit, hours, examMethod, nature, isExam, syllabus }] }
459
+ */
460
+ export function parsePlanHtml(html) {
461
+ if (isJwLoginExpired(html)) {
462
+ const err = new Error('jw login expired')
463
+ err.isJwLoginExpired = true
464
+ throw err
465
+ }
466
+ const table = findTableByHeaders(html, ['课程编号', '课程名称', '开课学期'])
467
+ if (!table) {
468
+ throw parseError('执行计划(未找到课程数据表)')
469
+ }
470
+ const col = (h) => table.headers.findIndex((x) => x.includes(h))
471
+ const idx = {
472
+ term: col('开课学期'),
473
+ courseCode: col('课程编号'),
474
+ courseName: col('课程名称'),
475
+ department: col('开课单位'),
476
+ credit: col('学分'),
477
+ hours: col('总学时'),
478
+ examMethod: col('考核方式'),
479
+ nature: col('课程性质'),
480
+ isExam: col('是否考试'),
481
+ syllabus: col('课程大纲'),
482
+ }
483
+ const courses = []
484
+ for (const row of table.rows) {
485
+ const item = {}
486
+ for (const [key, i] of Object.entries(idx)) {
487
+ item[key] = i >= 0 ? row[i] ?? '' : ''
488
+ }
489
+ if (!item.courseCode) continue
490
+ courses.push(item)
491
+ }
492
+ return { total: courses.length, courses }
493
+ }
494
+
495
+ /**
496
+ * 培养方案明细(/jsxsd/pyfa/topyfamx,GET 直出 75KB)。
497
+ *
498
+ * 页面怪癖(真实页实锤):TH 开标签用 </TD> 闭合(畸形标记),
499
+ * 配对正则全部失效——专用流式 tokenizer(按 <tr/<td 开标签切分,
500
+ * 不依赖闭合配对);课程表两层表头(「学时分类」分组头+6 子头);
501
+ * 体系列 rowspan 合并(首行 13 格、续行 12 格);总学时列含
502
+ * 「17 -->」尾缀;小计/合计行混在数据流中。
503
+ *
504
+ * → { objectives, courses: [{ system, group, courseCode, courseName,
505
+ * category, credit, hours: { lecture, practice, seminar, lab,
506
+ * computer, total }, term }] }
507
+ */
508
+ export function parsePyfaHtml(html) {
509
+ if (isJwLoginExpired(html)) {
510
+ const err = new Error('jw login expired')
511
+ err.isJwLoginExpired = true
512
+ throw err
513
+ }
514
+ const clean = (s) =>
515
+ String(s ?? '')
516
+ .replace(/<[^>]+>/g, '')
517
+ .replace(/&nbsp;/gi, ' ')
518
+ .replace(/&amp;/g, '&')
519
+ .replace(/[\u00a0\u3000]/g, ' ')
520
+ .replace(/\s+/g, ' ')
521
+ .trim()
522
+
523
+ // 培养目标段(「一、培养目标」到「二、」之前)
524
+ const plain = clean(html)
525
+ let objectives = ''
526
+ const objMatch = plain.match(/一、培养目标([\s\S]*?)(?=二、|$)/)
527
+ if (objMatch) objectives = objMatch[1].trim().slice(0, 2000)
528
+
529
+ // 流式切行
530
+ const rows = []
531
+ const rowRe = /<tr\b[^>]*>([\s\S]*?)(?=<tr\b|<\/table)/gi
532
+ let m
533
+ while ((m = rowRe.exec(html))) rows.push(m[1])
534
+ // 找表头行(体系+课号 同行)
535
+ const headerIdx = rows.findIndex(
536
+ (r) => /体系/.test(clean(r)) && /课号/.test(clean(r))
537
+ )
538
+ if (headerIdx === -1) {
539
+ throw parseError('培养方案明细(未找到课程设置总表表头)')
540
+ }
541
+ const cellsOf = (rowHtml) => {
542
+ const cells = []
543
+ const cellRe = /<t[hd]\b[^>]*>([\s\S]*?)(?=<t[hd]\b|<tr\b|<\/table|$)/gi
544
+ let c
545
+ while ((c = cellRe.exec(rowHtml))) {
546
+ const text = clean(c[1])
547
+ cells.push(text)
548
+ }
549
+ return cells
550
+ }
551
+
552
+ const courses = []
553
+ let lastSystem = ''
554
+ for (let i = headerIdx + 1; i < rows.length; i++) {
555
+ const cells = cellsOf(rows[i])
556
+ if (cells.length === 0) continue
557
+ // 两层表头的子头行(讲课/实践/讲座…)与汇总行跳过
558
+ const joined = cells.join(' ')
559
+ if (/^(讲课学时|实践学时)/.test(cells[0]) || cells[0] === '') {
560
+ if (cells[0] === '' && cells.length < 10) continue
561
+ }
562
+ if (/^(小计|合计)/.test(cells[0])) continue
563
+ // 数据行:12 格(体系沿用上一行)或 13 格(首列=体系)
564
+ if (cells.length === 13) {
565
+ lastSystem = cells[0] || lastSystem
566
+ cells.shift() // 统一成 12 格处理
567
+ } else if (cells.length !== 12) {
568
+ continue
569
+ }
570
+ const [group, courseCode, courseName, category, credit, lecture, practice, seminar, lab, computer, totalRaw, term] = cells
571
+ if (!/^\d{6,8}$/.test(courseCode)) continue // 非课程行(说明文字等)
572
+ courses.push({
573
+ system: lastSystem,
574
+ group,
575
+ courseCode,
576
+ courseName,
577
+ category,
578
+ credit,
579
+ hours: {
580
+ lecture,
581
+ practice,
582
+ seminar,
583
+ lab,
584
+ computer,
585
+ total: totalRaw.replace(/-->\s*$/, '').trim(),
586
+ },
587
+ term,
588
+ })
589
+ }
590
+ if (courses.length === 0) {
591
+ throw parseError('培养方案明细(课程总表零行)')
592
+ }
593
+ return { objectives, courses }
594
+ }
595
+
596
+ /**
597
+ * 考试安排(POST /jsxsd/xsks/xsksap_list——真实端点由表单页 JS
598
+ * queryKsap() 改写 action 而来,直 POST xsksap_query 只回表单页)。
599
+ * 参数:xnxqid 学期 / xqlb 类别码(1 期初 2 期中 3 期末,空=全部) /
600
+ * xqlbmc 类别名文本(JS 会在提交前填入选中项文本,重放须带上)。
601
+ * 真实结构 9 列:序号/考试场次/课程编号/课程名称/考试时间/考场/
602
+ * 座位号/准考证号/操作。考试未发布时整页仅「未查询到数据」。
603
+ * → { exams: [{ session, courseCode, courseName, time, location,
604
+ * seat, admissionTicket }] }(无数据返回空数组,不抛错)
605
+ */
606
+ export function parseExamsHtml(html) {
607
+ if (isJwLoginExpired(html)) {
608
+ const err = new Error('jw login expired')
609
+ err.isJwLoginExpired = true
610
+ throw err
611
+ }
612
+ if (html.includes('未查询到数据')) {
613
+ return { exams: [] }
614
+ }
615
+ const table = findTableByHeaders(html, ['课程名称', '考试时间', '考场'])
616
+ if (!table) {
617
+ throw parseError('考试安排(未找到数据表且非空结果页)')
618
+ }
619
+ const col = (h) => table.headers.findIndex((x) => x.includes(h))
620
+ const idx = {
621
+ session: col('考试场次'),
622
+ courseCode: col('课程编号'),
623
+ courseName: col('课程名称'),
624
+ time: col('考试时间'),
625
+ location: col('考场'),
626
+ seat: col('座位号'),
627
+ admissionTicket: col('准考证号'),
628
+ }
629
+ const exams = []
630
+ for (const row of table.rows) {
631
+ const item = {}
632
+ for (const [key, i] of Object.entries(idx)) {
633
+ item[key] = i >= 0 ? row[i] ?? '' : ''
634
+ }
635
+ if (!item.courseName && !item.courseCode) continue
636
+ exams.push(item)
637
+ }
638
+ return { exams }
639
+ }
640
+
641
+ /**
642
+ * 完成情况方案入口页(GET /jsxsd/xxwcqk/xxwcqk_idxOnxz.do——真实
643
+ * 菜单 URL 带 xxwcqk_ 前缀;全菜单树文档的 xstxkxdqk_ 前缀为误记)。
644
+ * 页面形态:每个修读方案一个独立 form,POST /jsxsd/xxwcqk/xxwcqkOnkcxz.do,
645
+ * 主修带隐藏码 ndzydm(专业代码)、辅修带 fxzydm(辅修专业代码),
646
+ * 另有恒空 jx0301zxjhid;「查看完成情况」按钮即提交该 form。
647
+ * → { plans: [{ type: '主修'|'辅修', name, code, codeField }] }
648
+ */
649
+ export function parseProgressPlansHtml(html) {
650
+ // 非法访问错误页(725B 无 table)会被登录过期判据误吞,先判
651
+ if (typeof html === 'string' && html.includes('非法访问')) {
652
+ throw parseError('完成情况方案入口(教务返回非法访问)')
653
+ }
654
+ if (isJwLoginExpired(html)) {
655
+ const err = new Error('jw login expired')
656
+ err.isJwLoginExpired = true
657
+ throw err
658
+ }
659
+ const plans = []
660
+ for (const m of html.matchAll(/<form\b[^>]*xxwcqkOnkcxz\.do[^>]*>([\s\S]*?)<\/form>/gi)) {
661
+ const form = m[1]
662
+ const codeField = /name="ndzydm"/i.test(form) ? 'ndzydm' : /name="fxzydm"/i.test(form) ? 'fxzydm' : ''
663
+ if (!codeField) continue
664
+ const code = (form.match(new RegExp(`name="${codeField}"[^>]*value="([^"]*)"`, 'i')) || [])[1] || ''
665
+ const visible = cleanCell(form)
666
+ // 方案名:form 可见文本去掉「修读方案:」与按钮文字
667
+ const name = visible.replace(/修读方案[::]?/, '').replace(/查看完成情况.*/, '').trim()
668
+ if (!code) continue
669
+ plans.push({ type: codeField === 'ndzydm' ? '主修' : '辅修', name, code, codeField })
670
+ }
671
+ if (plans.length === 0) {
672
+ throw parseError('完成情况方案入口(未找到修读方案表单)')
673
+ }
674
+ return { plans }
675
+ }
676
+
677
+ /**
678
+ * 完成情况数据页(POST xxwcqkOnkcxz.do 返回)。双表:
679
+ * 汇总表 th=课程性质/要求学分/已修学分/正修读学分/还需学分
680
+ * 明细表 th=课程编号/课程名称/学分/课程类别/课程性质/修读情况
681
+ * → { summary: [{ nature, required, earned, inProgress, remaining }],
682
+ * courses: [{ courseCode, courseName, credit, category, nature, status }] }
683
+ */
684
+ export function parseProgressDetailHtml(html) {
685
+ if (isJwLoginExpired(html)) {
686
+ const err = new Error('jw login expired')
687
+ err.isJwLoginExpired = true
688
+ throw err
689
+ }
690
+ const summaryTable = findTableByHeaders(html, ['课程性质', '要求学分', '还需学分'])
691
+ const courseTable = findTableByHeaders(html, ['课程编号', '修读情况'])
692
+ if (!summaryTable && !courseTable) {
693
+ throw parseError('完成情况数据页(未找到汇总/明细表)')
694
+ }
695
+ const summary = []
696
+ if (summaryTable) {
697
+ for (const row of summaryTable.rows) {
698
+ if (!row[0]) continue
699
+ summary.push({ nature: row[0], required: row[1] ?? '', earned: row[2] ?? '', inProgress: row[3] ?? '', remaining: row[4] ?? '' })
700
+ }
701
+ }
702
+ const courses = []
703
+ if (courseTable) {
704
+ for (const row of courseTable.rows) {
705
+ if (!/^\d{5,8}$/.test(row[0] ?? '')) continue // 跳过分组标题行(如「必修」)
706
+ courses.push({
707
+ courseCode: row[0],
708
+ courseName: row[1] ?? '',
709
+ credit: row[2] ?? '',
710
+ category: row[3] ?? '',
711
+ nature: row[4] ?? '',
712
+ status: row[5] ?? '',
713
+ })
714
+ }
715
+ }
716
+ return { summary, courses }
717
+ }
718
+
719
+ /**
720
+ * T13 通用简表解析:整页第一张含 th 的数据表 → { headers, rows }。
721
+ * rows 为字符串数组(与表头等长:短行补空、超长截断),表头保留
722
+ * 原文(中文自说明,重复表头如 levels 的双「笔试/机试/总成绩」
723
+ * 原样保留,按列位对应)。教务改版加列/换列序时输出仍自洽。
724
+ * @param {object} options
725
+ * @param {string} options.emptyText 空数据页特征文本(默认「未查询到数据」)
726
+ * @param {boolean} options.dropFirst 数据行首列丢弃(changes 的「+」展开图标列)
727
+ */
728
+ export function parseSimpleTable(html, { emptyText = '未查询到数据', dropFirst = false, tableIndex = 0 } = {}) {
729
+ if (typeof html === 'string' && html.includes('非法访问')) {
730
+ throw parseError('教务返回非法访问(接口未开放或入口受限)')
731
+ }
732
+ // 加载中壳页(如导师页)短小无表,会被登录过期判据误吞——先判
733
+ if (
734
+ typeof html === 'string' &&
735
+ html.includes('正在拼命加载中') &&
736
+ !/<th\b/i.test(html)
737
+ ) {
738
+ return { headers: [], rows: [] }
739
+ }
740
+ if (isJwLoginExpired(html)) {
741
+ const err = new Error('jw login expired')
742
+ err.isJwLoginExpired = true
743
+ throw err
744
+ }
745
+ // 全页含 th 的表按序取第 tableIndex 张;dropFirst 须在截齐前生效,
746
+ // 故自行展开 cells(parseTableByHeader 的 rows 已按表头截齐)
747
+ const candidates = []
748
+ for (const m of html.matchAll(/<table\b[\s\S]*?<\/table>/gi)) {
749
+ const headers = Array.from(m[0].matchAll(/<th\b[^>]*>([\s\S]*?)<\/th>/gi))
750
+ .map((h) => cleanCell(h[1]))
751
+ if (headers.length === 0) continue
752
+ const rows = []
753
+ for (const r of m[0].matchAll(/<tr\b[^>]*>([\s\S]*?)<\/tr>/gi)) {
754
+ if (/<th\b/i.test(r[1])) continue
755
+ let cells = Array.from(r[1].matchAll(/<td\b[^>]*>([\s\S]*?)<\/td>/gi)).map((c) => cleanCell(c[1]))
756
+ if (cells.length === 0) continue
757
+ if (dropFirst) cells = cells.slice(1)
758
+ rows.push(headers.map((_, i) => cells[i] ?? ''))
759
+ }
760
+ candidates.push({ headers, rows })
761
+ }
762
+ const table = candidates[tableIndex] || null
763
+ if (!table || table.headers.length === 0) {
764
+ throw parseError('简表(未找到数据表)')
765
+ }
766
+ if (html.includes(emptyText)) {
767
+ return { headers: table.headers, rows: [] }
768
+ }
769
+ return { headers: table.headers, rows: table.rows }
770
+ }
771
+
772
+ /**
773
+ * jwc.sdufe.edu.cn 通知公告列表解析(T17 双源之公开源)。
774
+ * 页面真实形态(zxdt/tzgg.htm):相对链接
775
+ * <a href="../info/1043/5965.htm" target="_blank">标题</a>
776
+ * 日期在 </a> 后约 120 字符窗口内(PHP Domain/JwNotice.php 备选
777
+ * 模式同源);URL 转 https://jwc.sdufe.edu.cn/info/… 绝对地址。
778
+ * → [{ title, url, date }]
779
+ */
780
+ export function parseJwcNotices(html, baseUrl = 'https://jwc.sdufe.edu.cn') {
781
+ const list = []
782
+ const linkRe = /<a\s+href="([^"]*\/info\/\d+\/\d+\.htm)"[^>]*>([^<]+)<\/a>/g
783
+ let m
784
+ while ((m = linkRe.exec(html))) {
785
+ let url = m[1]
786
+ if (!/^https?:/i.test(url)) {
787
+ // "../info/x" 相对于 "/zxdt/tzgg.htm" → "/info/x"
788
+ url = baseUrl + '/' + url.replace(/^(\.\.\/)+/, '')
789
+ }
790
+ const after = html.slice(m.index + m[0].length, m.index + m[0].length + 120)
791
+ const date = (after.match(/\d{4}-\d{2}-\d{2}/) || [])[0] || ''
792
+ list.push({ title: m[2].trim(), url, date })
793
+ }
794
+ return list
795
+ }
796
+
797
+ /**
798
+ * 补考报名页(bkbm_query)形态解析:非报名时间返回文案页
799
+ * 「当前不在报名时间范围内或未启用报名!」;报名期返回数据表。
800
+ * → { makeups: [...], note? }(空态带语义注记)
801
+ */
802
+ export function parseMakeupsHtml(html) {
803
+ if (typeof html === 'string' && html.includes('非法访问')) {
804
+ throw parseError('补考报名(教务返回非法访问)')
805
+ }
806
+ // 业务空态文案先于登录过期判据(短页无表会被误吞,T13 判例)
807
+ if (typeof html === 'string' && html.includes('不在报名时间范围')) {
808
+ return { makeups: [], note: '当前不在补考报名时间内(报名期开放后可查)' }
809
+ }
810
+ if (isJwLoginExpired(html)) {
811
+ const err = new Error('jw login expired')
812
+ err.isJwLoginExpired = true
813
+ throw err
814
+ }
815
+ // 报名期:按通用简表解析
816
+ try {
817
+ const table = parseSimpleTable(html)
818
+ if (table.headers.length === 0) {
819
+ return { makeups: [], note: '暂无补考记录' }
820
+ }
821
+ return { makeups: table.rows, headers: table.headers }
822
+ } catch {
823
+ return { makeups: [], note: '暂无补考记录' }
824
+ }
825
+ }