linke-sdufe 0.5.0 → 0.7.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/README.md +7 -1
- package/package.json +1 -1
- package/src/adapter.js +261 -20
- package/src/parsers.js +221 -6
- package/src/registry.js +2 -1
package/README.md
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
|
|
18
18
|
| 能力 | 签名 | 说明 |
|
|
19
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 |
|
|
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()}`,如宿主能取得重定向终点应同时提供 `url`;网络层失败 reject |
|
|
21
21
|
| toBase64 | `(bytes:Uint8Array) → string` | 验证码图片上行 |
|
|
22
22
|
| bytesToText | `(bytes:Uint8Array) → string` | 前 200 字节嗅探(宽松实现即可,ASCII 特征判定) |
|
|
23
23
|
| progress | `(msg:string) → void`(可缺省) | 进度输出 |
|
|
@@ -38,6 +38,12 @@ const { cookie, userInfo } = await adapter.login(
|
|
|
38
38
|
)
|
|
39
39
|
const schedule = await adapter.fetchSchedule(cookie, { term: '', week: '' })
|
|
40
40
|
|
|
41
|
+
// 公开通知:旧方法继续返回数组;需要可靠续页时消费原页真实下页链接
|
|
42
|
+
const first = await adapter.fetchPortalNoticePage('jwc')
|
|
43
|
+
const second = first.pagination.nextPageUrl
|
|
44
|
+
? await adapter.fetchPortalNoticePage('jwc', { pageUrl: first.pagination.nextPageUrl })
|
|
45
|
+
: null
|
|
46
|
+
|
|
41
47
|
// uni-app(垫片注入)
|
|
42
48
|
import { initSdufe } from 'linke-sdufe'
|
|
43
49
|
import { uniEnv } from '@/services/sdufePilot/uniEnv.js'
|
package/package.json
CHANGED
package/src/adapter.js
CHANGED
|
@@ -21,10 +21,13 @@ import {
|
|
|
21
21
|
parseCurrentTerm,
|
|
22
22
|
parseScheduleHtml,
|
|
23
23
|
parseScoresHtml,
|
|
24
|
+
parseScoresHtmlWithMeta,
|
|
24
25
|
parseCreditsHtml,
|
|
25
26
|
parseCoursesHtml,
|
|
26
27
|
parseGpaHtml,
|
|
28
|
+
parseGpaHtmlWithMeta,
|
|
27
29
|
parseXjHtml,
|
|
30
|
+
parseXjHtmlWithMeta,
|
|
28
31
|
parsePlanHtml,
|
|
29
32
|
parsePyfaHtml,
|
|
30
33
|
parseExamsHtml,
|
|
@@ -41,15 +44,173 @@ import {
|
|
|
41
44
|
const USER_AGENT = 'Apifox/1.0.0 (https://apifox.com)'
|
|
42
45
|
const REQUEST_TIMEOUT_MS = 20000
|
|
43
46
|
|
|
47
|
+
function missingFields(rows, fields) {
|
|
48
|
+
const missing = {}
|
|
49
|
+
for (const field of fields) {
|
|
50
|
+
const count = rows.filter((row) => row == null || String(row[field] == null ? '' : row[field]).trim() === '').length
|
|
51
|
+
if (count) missing[field] = count
|
|
52
|
+
}
|
|
53
|
+
return missing
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function personalReadMeta({ endpoint, method, status, scope, rows, requiredFields, parse, rangeReason, rangeIssue = null }) {
|
|
57
|
+
const requestComplete = Number(status) >= 200 && Number(status) < 300
|
|
58
|
+
const missing = missingFields(rows, requiredFields)
|
|
59
|
+
const summary = parse && typeof parse === 'object' ? parse : {}
|
|
60
|
+
const pagination = summary.pagination && typeof summary.pagination === 'object'
|
|
61
|
+
? summary.pagination
|
|
62
|
+
: { state: 'unknown', nextPageUrl: null, reason: '解析器未返回来源分页证明' }
|
|
63
|
+
const candidates = Number.isInteger(summary.candidateRowCount) ? summary.candidateRowCount : null
|
|
64
|
+
const parsed = Number.isInteger(summary.parsedRowCount) ? summary.parsedRowCount : rows.length
|
|
65
|
+
const skipped = Number.isInteger(summary.skippedRowCount) ? summary.skippedRowCount : null
|
|
66
|
+
const unresolvedSkipped = Number.isInteger(summary.unresolvedSkippedRowCount)
|
|
67
|
+
? summary.unresolvedSkippedRowCount
|
|
68
|
+
: skipped
|
|
69
|
+
let rangeState = 'unknown'
|
|
70
|
+
let reason = requestComplete ? '请求完成,但解析器没有给出可核验的完整范围证明' : `教务读取返回 HTTP ${status}`
|
|
71
|
+
if (requestComplete && rangeIssue) {
|
|
72
|
+
rangeState = rangeIssue.state === 'unknown' ? 'unknown' : 'partial'
|
|
73
|
+
reason = rangeIssue.reason || '实际返回范围与请求范围不一致'
|
|
74
|
+
} else if (requestComplete && pagination.state === 'partial') {
|
|
75
|
+
rangeState = 'partial'
|
|
76
|
+
reason = pagination.reason || '来源仍有未读分页'
|
|
77
|
+
} else if (requestComplete && pagination.state === 'unknown') {
|
|
78
|
+
rangeState = 'unknown'
|
|
79
|
+
reason = pagination.reason || '来源分页范围无法确定'
|
|
80
|
+
} else if (requestComplete && unresolvedSkipped != null && unresolvedSkipped > 0) {
|
|
81
|
+
rangeState = 'partial'
|
|
82
|
+
reason = `识别到 ${candidates == null ? '?' : candidates} 条候选记录,解析 ${parsed} 条,仍有 ${unresolvedSkipped} 条无法可靠解析`
|
|
83
|
+
} else if (requestComplete && summary.tableRecognized !== false && summary.cardRecognized !== false && candidates != null && candidates === parsed + (skipped || 0) && unresolvedSkipped === 0 && ['complete', 'not_applicable'].includes(pagination.state)) {
|
|
84
|
+
rangeState = 'complete'
|
|
85
|
+
reason = rangeReason
|
|
86
|
+
}
|
|
87
|
+
return {
|
|
88
|
+
source: { system: '山东财经大学强智教务系统', endpoint, method },
|
|
89
|
+
fetchedAt: new Date().toISOString(),
|
|
90
|
+
request: { state: requestComplete ? 'complete' : 'failed', httpStatus: Number(status) || null },
|
|
91
|
+
scope,
|
|
92
|
+
range: { state: requestComplete ? rangeState : 'failed', reason },
|
|
93
|
+
fields: { state: Object.keys(missing).length ? 'unknown' : 'complete', required: requiredFields, missing },
|
|
94
|
+
rowCount: rows.length,
|
|
95
|
+
parse: {
|
|
96
|
+
tableRecognized: summary.tableRecognized == null ? null : !!summary.tableRecognized,
|
|
97
|
+
cardRecognized: summary.cardRecognized == null ? null : !!summary.cardRecognized,
|
|
98
|
+
candidateRowCount: candidates,
|
|
99
|
+
parsedRowCount: parsed,
|
|
100
|
+
skippedRowCount: skipped,
|
|
101
|
+
unresolvedSkippedRowCount: unresolvedSkipped,
|
|
102
|
+
skippedReasons: summary.skippedReasons || {}
|
|
103
|
+
},
|
|
104
|
+
pagination: { ...pagination, nextCursor: null }
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function stableTextFingerprint(text) {
|
|
109
|
+
let hash = 0x811c9dc5
|
|
110
|
+
const input = String(text || '')
|
|
111
|
+
for (let i = 0; i < input.length; i++) {
|
|
112
|
+
hash ^= input.charCodeAt(i)
|
|
113
|
+
hash = Math.imul(hash, 0x01000193)
|
|
114
|
+
}
|
|
115
|
+
return `fnv1a32:${(hash >>> 0).toString(16).padStart(8, '0')}:${input.length}`
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function portalNoticeRecordId(url) {
|
|
119
|
+
const match = String(url || '').match(/\/info\/(\d+)\/([^/?#]+)\.htm(?:[?#]|$)/i)
|
|
120
|
+
return match ? `${match[1]}:${match[2]}` : ''
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function portalNoticeContinuationFingerprint({ site, finalUrl, items, pagination }) {
|
|
124
|
+
// 续读只绑定列表事实和实际分页位置;整页 HTML hash 另作诊断。
|
|
125
|
+
// 因此 nonce、页脚时钟或统计脚本变化不会让合法游标失效,
|
|
126
|
+
// 但记录增删/重排/标题/日期/URL 及下一页目标变化仍会改变指纹。
|
|
127
|
+
const projection = {
|
|
128
|
+
v: 1,
|
|
129
|
+
site: String(site || ''),
|
|
130
|
+
position: {
|
|
131
|
+
finalUrl: String(finalUrl || ''),
|
|
132
|
+
page: pagination && pagination.page == null ? null : pagination.page,
|
|
133
|
+
totalPages: pagination && pagination.totalPages == null ? null : pagination.totalPages,
|
|
134
|
+
totalItems: pagination && pagination.totalItems == null ? null : pagination.totalItems,
|
|
135
|
+
nextPageUrl: String(pagination && pagination.nextPageUrl || ''),
|
|
136
|
+
},
|
|
137
|
+
items: (items || []).map((item) => ({
|
|
138
|
+
id: portalNoticeRecordId(item && item.url),
|
|
139
|
+
url: String(item && item.url || ''),
|
|
140
|
+
title: String(item && item.title || '').trim(),
|
|
141
|
+
date: String(item && item.date || '').trim(),
|
|
142
|
+
})),
|
|
143
|
+
}
|
|
144
|
+
return `notice-list-v1:${stableTextFingerprint(JSON.stringify(projection))}`
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function portalPageUrl(src, page = 1) {
|
|
148
|
+
if (page > 1 && !src.pagePath) return null
|
|
149
|
+
const path = page <= 1
|
|
150
|
+
? src.listPath
|
|
151
|
+
: `${src.pagePath}/${src.pageNumbered ? page : page - 1}.htm`
|
|
152
|
+
return src.origin + path
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function validatePortalListUrl(src, rawUrl, baseUrl) {
|
|
156
|
+
let parsed
|
|
157
|
+
try {
|
|
158
|
+
parsed = new URL(rawUrl, baseUrl || src.origin + src.listPath)
|
|
159
|
+
} catch (_) {
|
|
160
|
+
return null
|
|
161
|
+
}
|
|
162
|
+
if (parsed.origin !== src.origin || parsed.username || parsed.password || parsed.port) return null
|
|
163
|
+
const allowed = parsed.pathname === src.listPath
|
|
164
|
+
|| (src.pagePath && parsed.pathname.startsWith(`${src.pagePath}/`) && /^\d+\.htm$/.test(parsed.pathname.slice(src.pagePath.length + 1)))
|
|
165
|
+
if (!allowed) return null
|
|
166
|
+
parsed.hash = ''
|
|
167
|
+
return parsed.toString()
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function parsePortalPagination(html, src, pageUrl) {
|
|
171
|
+
const source = String(html || '')
|
|
172
|
+
const summary = source.match(/共\s*(\d+)\s*条(?: |\s)*?(\d+)\s*\/\s*(\d+)/i)
|
|
173
|
+
const page = summary ? Number(summary[2]) : null
|
|
174
|
+
const totalPages = summary ? Number(summary[3]) : null
|
|
175
|
+
const totalItems = summary ? Number(summary[1]) : null
|
|
176
|
+
let nextPageUrl = null
|
|
177
|
+
let rejectedNextUrl = null
|
|
178
|
+
const anchorRe = /<a\b[^>]*href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi
|
|
179
|
+
let match
|
|
180
|
+
while ((match = anchorRe.exec(source))) {
|
|
181
|
+
const label = match[2].replace(/<[^>]+>/g, '').replace(/ /gi, ' ').trim()
|
|
182
|
+
if (!/^(下一页|下页)$/.test(label)) continue
|
|
183
|
+
const validated = validatePortalListUrl(src, match[1], pageUrl)
|
|
184
|
+
if (validated) nextPageUrl = validated
|
|
185
|
+
else rejectedNextUrl = match[1]
|
|
186
|
+
break
|
|
187
|
+
}
|
|
188
|
+
const terminal = !!(page && totalPages && page >= totalPages && !nextPageUrl)
|
|
189
|
+
let state = 'unknown'
|
|
190
|
+
let reason = '页面未提供可验证的页码汇总或下一页链接'
|
|
191
|
+
if (rejectedNextUrl) {
|
|
192
|
+
state = 'failed'
|
|
193
|
+
reason = '页面的下一页链接越出已注册站点或栏目边界'
|
|
194
|
+
} else if (nextPageUrl) {
|
|
195
|
+
state = 'partial'
|
|
196
|
+
reason = '页面提供了已校验的下一页链接'
|
|
197
|
+
} else if (terminal) {
|
|
198
|
+
state = 'complete'
|
|
199
|
+
reason = '页码汇总证明当前页为末页且没有下一页链接'
|
|
200
|
+
}
|
|
201
|
+
return { page, totalPages, totalItems, nextPageUrl, terminal, state, reason, rejectedNextUrl }
|
|
202
|
+
}
|
|
203
|
+
|
|
44
204
|
/**
|
|
45
|
-
*
|
|
205
|
+
* 校园官网公开通知主源(T46/T55;博达 CMS 同构,勘察实锤 2026-09-04/05)。
|
|
46
206
|
* 翻页:listPath=栏目首页,page N → pagePath/{N-1}.htm。
|
|
47
207
|
* 学工部通知栏目实测=info/1093(数据源地图旧记 1090 实测 404)。
|
|
48
208
|
*/
|
|
49
209
|
/**
|
|
50
210
|
* 校园官网公开通知源注册表(T46 起 36 源;数据源地图 v4 全站探测 +
|
|
51
211
|
* 2026-09-04 执行者逐站实勘:列表/栏目/翻页/详情容器逐站 curl 实证)。
|
|
52
|
-
*
|
|
212
|
+
* 翻页:默认 listPath 去掉 .htm 即 pagerPath(page N → {pagerPath}/{N-1}.htm);
|
|
213
|
+
* pageNumbered=true 的站点使用站内页码(page N → {pagerPath}/{N}.htm)。
|
|
53
214
|
* pagePath 为空=该源翻页形态特殊未实证(如 jinrong),page>1 明确报错。
|
|
54
215
|
* infoPaths:列表条目栏目过滤(防导航混入);详情白名单按 origin(不限栏目)。
|
|
55
216
|
* 不接:zgjjyjy(无通知栏目)、xcb(内容发新闻网)、tuanwei/grads 等
|
|
@@ -95,6 +256,8 @@ export const PORTAL_NOTICE_SOURCES = {
|
|
|
95
256
|
sjc: { name: '审计处', origin: 'https://sjc.sdufe.edu.cn', listPath: '/gzdt.htm', pagePath: '/gzdt', infoPaths: ['1006'] },
|
|
96
257
|
cwc: { name: '财务处', origin: 'https://cwc.sdufe.edu.cn', listPath: '/tzgg.htm', pagePath: '/tzgg', infoPaths: ['1024'] },
|
|
97
258
|
sclx: { name: '出国留学培训基地', origin: 'https://sclx.sdufe.edu.cn', listPath: '/index/tzgg.htm', pagePath: '/index/tzgg', infoPaths: ['1078'] },
|
|
259
|
+
alumni: { name: '校友会新闻动态', origin: 'https://alumni.sdufe.edu.cn', listPath: '/index/xwdt.htm', pagePath: '/index/xwdt', pageNumbered: true, infoPaths: ['1034'] },
|
|
260
|
+
international: { name: '国际交流与合作处通知公告', origin: 'https://international.sdufe.edu.cn', listPath: '/index/tzgg.htm', pagePath: '/index/tzgg', pageNumbered: true, infoPaths: ['1024'] },
|
|
98
261
|
}
|
|
99
262
|
|
|
100
263
|
/**
|
|
@@ -109,6 +272,10 @@ export const PORTAL_PAGES = {
|
|
|
109
272
|
xqbc: { name: '校车班线', url: 'https://www.sdufe.edu.cn/xyfw/xqbc.htm' },
|
|
110
273
|
xydh: { name: '校园电话', url: 'https://www.sdufe.edu.cn/xyfw/xydh.htm' },
|
|
111
274
|
zxxl: { name: '校历', url: 'https://www.sdufe.edu.cn/xyfw/zxxl.htm' },
|
|
275
|
+
// T56 主站学校概况具名页:逐页实勘 2026-09-05,正文走 parsePortalPage。
|
|
276
|
+
lrld: { name: '历任领导', url: 'https://www.sdufe.edu.cn/xxgk_/lrld.htm' },
|
|
277
|
+
cdbs: { name: '财大标识', url: 'https://www.sdufe.edu.cn/xxgk_/cdbs.htm' },
|
|
278
|
+
cdzc: { name: '财大章程', url: 'https://www.sdufe.edu.cn/xxgk_/cdzc.htm' },
|
|
112
279
|
// T47 学院简介/领导页(16 站 24 key,照单注册清单 docs/plan/crawl/t47-page-key-registry.md
|
|
113
280
|
// ——地图 v9+批次 8 全量遍历双向验证;命名 {学院缩写}-xyjj=学院简介 / -ldr=学院领导)。
|
|
114
281
|
// 领导页为表格版式(姓名/职务/分工三列)无标准博达容器,走 parsePortalPage body 兜底。
|
|
@@ -331,14 +498,28 @@ export function createSdufeAdapter(env) {
|
|
|
331
498
|
return parseScheduleHtml(res.text || '')
|
|
332
499
|
},
|
|
333
500
|
|
|
334
|
-
/**
|
|
335
|
-
async
|
|
501
|
+
/** 抓成绩及范围证明:term 空 = 全部学期;旧 fetchScores 仍只返回 rows[]。 */
|
|
502
|
+
async fetchScoresWithMeta(cookie, { term = '' } = {}) {
|
|
336
503
|
const form = `kksj=${encodeURIComponent(term)}&xsfs=all`
|
|
337
|
-
const
|
|
504
|
+
const endpoint = '/jsxsd/kscj/cjcx_list'
|
|
505
|
+
const res = await this.request(`${this.baseUrl}${endpoint}`, 'POST', {
|
|
338
506
|
body: form,
|
|
339
507
|
cookie,
|
|
340
508
|
})
|
|
341
|
-
|
|
509
|
+
const parsed = parseScoresHtmlWithMeta(res.text || '')
|
|
510
|
+
const rows = parsed.rows
|
|
511
|
+
const actualTerms = [...new Set(rows.map((row) => String(row && row.term || '').trim()).filter(Boolean))].sort()
|
|
512
|
+
const requestedTerm = String(term || '').trim()
|
|
513
|
+
const mismatchedTerms = requestedTerm ? actualTerms.filter((actual) => actual !== requestedTerm) : []
|
|
514
|
+
return { data: rows, meta: personalReadMeta({ endpoint, method: 'POST', status: res.status,
|
|
515
|
+
scope: { term: requestedTerm || 'all', actualTerms, termMatch: requestedTerm ? mismatchedTerms.length === 0 : true, scoreDisplay: 'all' }, rows, parse: parsed.meta,
|
|
516
|
+
requiredFields: ['term', 'courseCode', 'courseName', 'scoreText', 'credit', 'nature'],
|
|
517
|
+
rangeIssue: mismatchedTerms.length ? { state: 'partial', reason: `请求学期 ${requestedTerm},但实际返回包含 ${mismatchedTerms.join('、')},不能证明指定学期范围已满足` } : null,
|
|
518
|
+
rangeReason: term ? `已用 kksj=${term} 且 xsfs=all 读取指定学期成绩页;解析零行会失败` : '已用空 kksj 且 xsfs=all 读取全部学期成绩页;解析零行会失败' }) }
|
|
519
|
+
},
|
|
520
|
+
|
|
521
|
+
async fetchScores(cookie, { term = '' } = {}) {
|
|
522
|
+
return (await this.fetchScoresWithMeta(cookie, { term })).data
|
|
342
523
|
},
|
|
343
524
|
|
|
344
525
|
/** 抓学分修读(通选课统计,GET 直出) */
|
|
@@ -370,16 +551,36 @@ export function createSdufeAdapter(env) {
|
|
|
370
551
|
return parseCoursesHtml(res.text || '')
|
|
371
552
|
},
|
|
372
553
|
|
|
373
|
-
/**
|
|
554
|
+
/** 平均学分绩点(含主修/辅修统计行);旧 fetchGpa 返回形态不变。 */
|
|
555
|
+
async fetchGpaWithMeta(cookie) {
|
|
556
|
+
const endpoint = '/jsxsd/kscj/cjcx_avg'
|
|
557
|
+
const res = await this.request(`${this.baseUrl}${endpoint}`, 'GET', { cookie })
|
|
558
|
+
const parsed = parseGpaHtmlWithMeta(res.text || '')
|
|
559
|
+
const data = parsed.data
|
|
560
|
+
return { data, meta: personalReadMeta({ endpoint, method: 'GET', status: res.status,
|
|
561
|
+
scope: { rows: 'major_and_minor_statistics' }, rows: data.rows, parse: parsed.meta,
|
|
562
|
+
requiredFields: ['studentId', 'name', 'major', 'className', 'level', 'totalCredits', 'courseCount', 'averageScore', 'averageGrade', 'gpa', 'majorType'],
|
|
563
|
+
rangeReason: '已读取平均学分绩点整表;主修与页面返回的全部辅修统计行均保留,未找到表或零行会失败' }) }
|
|
564
|
+
},
|
|
565
|
+
|
|
374
566
|
async fetchGpa(cookie) {
|
|
375
|
-
|
|
376
|
-
|
|
567
|
+
return (await this.fetchGpaWithMeta(cookie)).data
|
|
568
|
+
},
|
|
569
|
+
|
|
570
|
+
/** 学籍卡片及核心字段证明;旧 fetchXj 返回形态不变。 */
|
|
571
|
+
async fetchXjWithMeta(cookie, { full = false } = {}) {
|
|
572
|
+
const endpoint = '/jsxsd/grxx/xsxx'
|
|
573
|
+
const res = await this.request(`${this.baseUrl}${endpoint}`, 'GET', { cookie })
|
|
574
|
+
const parsed = parseXjHtmlWithMeta(res.text || '', { full })
|
|
575
|
+
const data = parsed.data
|
|
576
|
+
return { data, meta: personalReadMeta({ endpoint, method: 'GET', status: res.status,
|
|
577
|
+
scope: { card: 'logged_in_student', fields: full ? 'core_plus_non_sensitive_whitelist' : 'core_only' }, rows: [data], parse: parsed.meta,
|
|
578
|
+
requiredFields: ['studentId', 'department', 'major', 'duration', 'className', 'level', 'grade'],
|
|
579
|
+
rangeReason: '已读取本机登录学生学籍卡片;学号用于确认卡片有效但宿主可继续裁剪,敏感字段不进入返回' }) }
|
|
377
580
|
},
|
|
378
581
|
|
|
379
|
-
/** 学籍卡片(默认裁剪敏感字段,full=true 输出白名单内扩展字段) */
|
|
380
582
|
async fetchXj(cookie, { full = false } = {}) {
|
|
381
|
-
|
|
382
|
-
return parseXjHtml(res.text || '', { full })
|
|
583
|
+
return (await this.fetchXjWithMeta(cookie, { full })).data
|
|
383
584
|
},
|
|
384
585
|
|
|
385
586
|
/** 培养执行计划(GET 直出,逐学期课程列表) */
|
|
@@ -494,30 +695,47 @@ export function createSdufeAdapter(env) {
|
|
|
494
695
|
},
|
|
495
696
|
|
|
496
697
|
/**
|
|
497
|
-
* 校园官网公开通知源列表(T46
|
|
698
|
+
* 校园官网公开通知源列表(T46/T55,博达 CMS 同构
|
|
498
699
|
* 一套解析,逐站实勘配置见 PORTAL_NOTICE_SOURCES)。
|
|
499
700
|
* 翻页口径:page 1=listPath,page N={pagePath}/{N-1}.htm;
|
|
500
701
|
* pagePath 为空=该源翻页未实证(page>1 明确报错,不静默回首页)。
|
|
501
702
|
* 礼貌纪律:只抓请求的页,不做归档批量预取。
|
|
502
703
|
*/
|
|
503
704
|
async fetchPortalNotices(site, page = 1) {
|
|
705
|
+
const result = await this.fetchPortalNoticePage(site, { page })
|
|
706
|
+
return result.items
|
|
707
|
+
},
|
|
708
|
+
|
|
709
|
+
/**
|
|
710
|
+
* 校园官网公开通知的单页事实合同。与 fetchPortalNotices 的数组返回并存:
|
|
711
|
+
* 新调用方可沿页面真实“下页”链接续读,并获得页码、末页与内容指纹证据。
|
|
712
|
+
*/
|
|
713
|
+
async fetchPortalNoticePage(site, { page = 1, pageUrl = '' } = {}) {
|
|
504
714
|
const src = PORTAL_NOTICE_SOURCES[site]
|
|
505
715
|
if (!src) {
|
|
506
716
|
throw new LinkeError('BAD_SOURCE', `未知通知源:${site}`, {
|
|
507
717
|
exitCode: EXIT.GENERAL,
|
|
508
|
-
hint: 'source 取源注册表 key(jwc=教务处 / main=主站 / xgb=学工部 / lib=图书馆 / yjsy=研究生院 / news=新闻网 / 各学院与职能部门拼音缩写)',
|
|
718
|
+
hint: 'source 取源注册表 key(jwc=教务处 / alumni=校友会新闻动态 / international=国际交流与合作处通知公告 / main=主站 / xgb=学工部 / lib=图书馆 / yjsy=研究生院 / news=新闻网 / 各学院与职能部门拼音缩写)',
|
|
509
719
|
})
|
|
510
720
|
}
|
|
511
|
-
if (page > 1 && !src.pagePath) {
|
|
721
|
+
if (!pageUrl && page > 1 && !src.pagePath) {
|
|
512
722
|
throw new LinkeError('NO_PAGER', `${src.name}暂不支持翻页(该站翻页形态特殊未实证)`, {
|
|
513
723
|
exitCode: EXIT.GENERAL,
|
|
514
724
|
hint: '本源仅可查第一页(page=1)',
|
|
515
725
|
})
|
|
516
726
|
}
|
|
517
|
-
const
|
|
727
|
+
const requestedUrl = pageUrl
|
|
728
|
+
? validatePortalListUrl(src, pageUrl)
|
|
729
|
+
: portalPageUrl(src, page)
|
|
730
|
+
if (!requestedUrl) {
|
|
731
|
+
throw new LinkeError('BAD_PAGER_URL', `${src.name}分页地址不在已注册栏目边界`, {
|
|
732
|
+
exitCode: EXIT.GENERAL,
|
|
733
|
+
hint: '只能使用 fetchPortalNoticePage 上一次返回的 nextPageUrl',
|
|
734
|
+
})
|
|
735
|
+
}
|
|
518
736
|
let response
|
|
519
737
|
try {
|
|
520
|
-
response = await env.fetch(
|
|
738
|
+
response = await env.fetch(requestedUrl, {
|
|
521
739
|
headers: { 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) linke-cli' },
|
|
522
740
|
redirect: 'follow',
|
|
523
741
|
timeoutMs: 15000,
|
|
@@ -528,10 +746,33 @@ export function createSdufeAdapter(env) {
|
|
|
528
746
|
if (!response.ok) {
|
|
529
747
|
throw networkError(`抓取${src.name}公告页(HTTP ${response.status})`, null)
|
|
530
748
|
}
|
|
749
|
+
const finalUrl = response.url ? validatePortalListUrl(src, response.url) : requestedUrl
|
|
750
|
+
if (!finalUrl) {
|
|
751
|
+
throw new LinkeError('BAD_REDIRECT', `${src.name}公告页重定向越出已注册栏目边界`, {
|
|
752
|
+
exitCode: EXIT.GENERAL,
|
|
753
|
+
})
|
|
754
|
+
}
|
|
755
|
+
const html = await response.text()
|
|
531
756
|
// 栏目过滤:列表页含导航/侧栏的 info 链接(如主站「学校概况」),
|
|
532
757
|
// 只保留本源通知栏目条目
|
|
533
|
-
|
|
758
|
+
const items = parsePortalNotices(html, src.origin)
|
|
534
759
|
.filter((x) => !src.infoPaths || !src.infoPaths.length || src.infoPaths.some((c) => x.url.includes(`/info/${c}/`)))
|
|
760
|
+
.map((x) => ({ ...x, dateSource: x.date ? 'list_page' : 'unavailable' }))
|
|
761
|
+
const pagination = parsePortalPagination(html, src, finalUrl)
|
|
762
|
+
const continuationFingerprint = portalNoticeContinuationFingerprint({ site, finalUrl, items, pagination })
|
|
763
|
+
return {
|
|
764
|
+
site,
|
|
765
|
+
sourceName: src.name,
|
|
766
|
+
requestedUrl,
|
|
767
|
+
finalUrl,
|
|
768
|
+
redirectVerified: !!response.url,
|
|
769
|
+
fetchedAt: new Date().toISOString(),
|
|
770
|
+
contentFingerprint: stableTextFingerprint(html),
|
|
771
|
+
continuationFingerprintVersion: 1,
|
|
772
|
+
continuationFingerprint,
|
|
773
|
+
items,
|
|
774
|
+
pagination,
|
|
775
|
+
}
|
|
535
776
|
},
|
|
536
777
|
|
|
537
778
|
/**
|
|
@@ -544,7 +785,7 @@ export function createSdufeAdapter(env) {
|
|
|
544
785
|
if (!page) {
|
|
545
786
|
throw new LinkeError('BAD_PAGE', `未知具名页面:${pageKey}(可选:${Object.keys(PORTAL_PAGES).join('/')})`, {
|
|
546
787
|
exitCode: EXIT.GENERAL,
|
|
547
|
-
hint: '学校级:xxjj=学校简介 / xrld=现任领导 / zuzjg=组织机构 / xqbc=校车班线 / xydh=校园电话 / zxxl=校历;学院级(T47):{学院缩写}-xyjj=学院简介 / {学院缩写}-ldr=学院领导(如 gkgc-ldr=管科学院领导)',
|
|
788
|
+
hint: '学校级:xxjj=学校简介 / xrld=现任领导 / lrld=历任领导 / zuzjg=组织机构 / cdbs=财大标识 / cdzc=财大章程 / xqbc=校车班线 / xydh=校园电话 / zxxl=校历;学院级(T47):{学院缩写}-xyjj=学院简介 / {学院缩写}-ldr=学院领导(如 gkgc-ldr=管科学院领导)',
|
|
548
789
|
})
|
|
549
790
|
}
|
|
550
791
|
let response
|
|
@@ -587,7 +828,7 @@ export function createSdufeAdapter(env) {
|
|
|
587
828
|
if (!src || !/^\/info\/\d+\/\d+\.htm$/.test(parsed.pathname)) {
|
|
588
829
|
throw new LinkeError('BAD_URL', `URL 不在校内通知源范围:${url}`, {
|
|
589
830
|
exitCode: EXIT.GENERAL,
|
|
590
|
-
hint: `仅支持通知源注册表内各站(jwc/main/xgb/lib/yjsy/news/各学院等)的 info/{栏目}/{id}.htm 详情页`,
|
|
831
|
+
hint: `仅支持通知源注册表内各站(jwc/alumni/international/main/xgb/lib/yjsy/news/各学院等)的 info/{栏目}/{id}.htm 详情页`,
|
|
591
832
|
})
|
|
592
833
|
}
|
|
593
834
|
let response
|
package/src/parsers.js
CHANGED
|
@@ -146,7 +146,7 @@ export function findTableByHeaders(html, mustInclude) {
|
|
|
146
146
|
* T10:补捕获学分列(真实页列序 序号/学期/编号/名称/成绩/学分/绩点/
|
|
147
147
|
* 考试性质/课程性质/课程属性/辅修——学分紧跟成绩双闭合单元格后)。
|
|
148
148
|
*/
|
|
149
|
-
|
|
149
|
+
function parseScoresRows(html) {
|
|
150
150
|
if (isJwLoginExpired(html)) {
|
|
151
151
|
const err = new Error('jw login expired')
|
|
152
152
|
err.isJwLoginExpired = true
|
|
@@ -180,7 +180,7 @@ export function parseScoresHtml(html) {
|
|
|
180
180
|
// 变体一:行首带序号列,第二列学期(现役主口径,PHP matchesWithLeading)
|
|
181
181
|
const withLeading = Array.from(
|
|
182
182
|
cleaned.matchAll(
|
|
183
|
-
/<tr
|
|
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
184
|
)
|
|
185
185
|
)
|
|
186
186
|
for (const m of withLeading) {
|
|
@@ -193,6 +193,28 @@ export function parseScoresHtml(html) {
|
|
|
193
193
|
}
|
|
194
194
|
}
|
|
195
195
|
|
|
196
|
+
// 兼容同一成绩表中缺少“控制成绩显示”注释、但单元格结构仍完整的记录。
|
|
197
|
+
// 注释只是前端展示实现,不能同时充当候选记录清单和解析成功条件。
|
|
198
|
+
for (const candidate of scoreCandidateRows(html)) {
|
|
199
|
+
if (/控制成绩显示/.test(candidate.html)) continue
|
|
200
|
+
const cells = candidate.cells
|
|
201
|
+
const termIndex = cells.findIndex((cell) => TERM_RE.test(cell.text))
|
|
202
|
+
if (termIndex < 0 || cells.length < termIndex + 5) continue
|
|
203
|
+
const scoreCell = cells[termIndex + 3]
|
|
204
|
+
// 无表头兼容页仍要求成绩格保留链接结构,避免把普通布局表误认成成绩表。
|
|
205
|
+
if (!candidate.headerRecognized && !/<a\b/i.test(scoreCell.raw)) continue
|
|
206
|
+
const before = rows.length
|
|
207
|
+
addRow(
|
|
208
|
+
cells[termIndex].text,
|
|
209
|
+
cells[termIndex + 1].text,
|
|
210
|
+
cells[termIndex + 2].text,
|
|
211
|
+
cells[termIndex + 4].text,
|
|
212
|
+
scoreCell.text,
|
|
213
|
+
cells[termIndex + 7] ? cells[termIndex + 7].text : ''
|
|
214
|
+
)
|
|
215
|
+
if (rows.length === before) continue
|
|
216
|
+
}
|
|
217
|
+
|
|
196
218
|
// 变体二:legacy 无前导学期列(PHP matchesLegacy,仅在变体一整页零命中时启用)
|
|
197
219
|
if (rows.length === 0) {
|
|
198
220
|
const legacy = Array.from(
|
|
@@ -211,6 +233,142 @@ export function parseScoresHtml(html) {
|
|
|
211
233
|
return rows
|
|
212
234
|
}
|
|
213
235
|
|
|
236
|
+
function isDisabledPaginationControl(attrsValue) {
|
|
237
|
+
const attrs = String(attrsValue || '')
|
|
238
|
+
const nativeDisabled = /(?:^|\s)disabled(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s]+))?(?=\s|$)/i.test(attrs)
|
|
239
|
+
const ariaDisabled = /(?:^|\s)aria-disabled\s*=\s*(?:"true"|'true'|true)(?=\s|$)/i.test(attrs)
|
|
240
|
+
const classValue = (attrs.match(/(?:^|\s)class\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s]+))/i) || []).slice(1).find((value) => value != null) || ''
|
|
241
|
+
const classDisabled = classValue.toLowerCase().split(/\s+/).includes('disabled')
|
|
242
|
+
return nativeDisabled || ariaDisabled || classDisabled
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function paginationEvidence(html, candidateRowCount, label = '成绩') {
|
|
246
|
+
const source = String(html || '')
|
|
247
|
+
const visible = cleanCell(source)
|
|
248
|
+
const totalMatch = visible.match(/共\s*(\d+)\s*页/)
|
|
249
|
+
const totalPages = totalMatch ? Number(totalMatch[1]) : null
|
|
250
|
+
const currentMatch = visible.match(/第\s*(\d+)\s*页/)
|
|
251
|
+
const currentPage = currentMatch ? Number(currentMatch[1]) : null
|
|
252
|
+
const totalRowsMatch = visible.match(/共\s*(\d+)\s*(?:条|条记录|条数据)/)
|
|
253
|
+
const totalRows = totalRowsMatch ? Number(totalRowsMatch[1]) : null
|
|
254
|
+
const next = Array.from(source.matchAll(/<a\b([^>]*)>([\s\S]*?)<\/a>/gi))
|
|
255
|
+
.map((m) => {
|
|
256
|
+
const attrs = String(m[1] || '')
|
|
257
|
+
const href = (attrs.match(/\bhref=["']([^"']+)["']/i) || [])[1] || ''
|
|
258
|
+
const disabled = isDisabledPaginationControl(attrs)
|
|
259
|
+
return { href, text: cleanCell(m[2]), disabled }
|
|
260
|
+
})
|
|
261
|
+
.find((row) => /下一页|下页|next/i.test(row.text) && !row.disabled && row.href && row.href !== '#' && !/^javascript:/i.test(row.href))
|
|
262
|
+
const nextControls = Array.from(source.matchAll(/<(a|button|input)\b([^>]*)>([\s\S]*?)<\/\1>|<input\b([^>]*)\/?>/gi))
|
|
263
|
+
.map((m) => {
|
|
264
|
+
const attrs = String(m[2] || m[4] || '')
|
|
265
|
+
const text = cleanCell(m[3] || (attrs.match(/\bvalue=["']([^"']*)["']/i) || [])[1] || '')
|
|
266
|
+
const disabled = isDisabledPaginationControl(attrs)
|
|
267
|
+
return { text, disabled }
|
|
268
|
+
})
|
|
269
|
+
.filter((control) => /下一页|下页|next/i.test(control.text))
|
|
270
|
+
const enabledNextControl = nextControls.some((control) => !control.disabled)
|
|
271
|
+
const hasMoreByPageCount = Number.isInteger(totalPages) && totalPages > 1
|
|
272
|
+
const hasMoreByRowCount = Number.isInteger(totalRows) && totalRows > candidateRowCount
|
|
273
|
+
const contradictoryRowCount = Number.isInteger(totalRows) && totalRows < candidateRowCount
|
|
274
|
+
const hasMore = !!next || enabledNextControl || hasMoreByPageCount || hasMoreByRowCount
|
|
275
|
+
const ambiguousNextText = /下一页|下页/i.test(visible) && !next && nextControls.length === 0
|
|
276
|
+
const state = hasMore ? 'partial' : contradictoryRowCount || ambiguousNextText ? 'unknown' : 'complete'
|
|
277
|
+
return {
|
|
278
|
+
detected: totalPages != null || totalRows != null || /下一页|下页/i.test(source),
|
|
279
|
+
state,
|
|
280
|
+
currentPage,
|
|
281
|
+
totalPages,
|
|
282
|
+
totalRows,
|
|
283
|
+
nextPageUrl: next ? next.href : null,
|
|
284
|
+
reason: hasMore
|
|
285
|
+
? `${label}响应明示多页、可用下一页控件或未交付记录;单次响应不能证明此前页与后续页均已读取`
|
|
286
|
+
: contradictoryRowCount
|
|
287
|
+
? `${label}响应宣称共 ${totalRows} 条,但识别到 ${candidateRowCount} 条候选记录,来源计数矛盾`
|
|
288
|
+
: ambiguousNextText
|
|
289
|
+
? `${label}响应出现分页提示,但无法判断下一页控件是否可用`
|
|
290
|
+
: `已识别${label}结果行,响应未显示未读分页或计数缺口`
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function scoreCandidateRows(html) {
|
|
295
|
+
const candidates = []
|
|
296
|
+
for (const tableMatch of String(html || '').matchAll(/<table\b[\s\S]*?<\/table>/gi)) {
|
|
297
|
+
const tableHtml = tableMatch[0]
|
|
298
|
+
const headers = Array.from(tableHtml.matchAll(/<th\b[^>]*>([\s\S]*?)<\/th>/gi)).map((m) => cleanCell(m[1]))
|
|
299
|
+
const headerRecognized = headers.some((h) => h.includes('学期'))
|
|
300
|
+
&& headers.some((h) => h.includes('课程'))
|
|
301
|
+
&& headers.some((h) => h.includes('成绩'))
|
|
302
|
+
for (const rowMatch of tableHtml.matchAll(/<tr\b[^>]*>([\s\S]*?)<\/tr>/gi)) {
|
|
303
|
+
const rowHtml = rowMatch[0]
|
|
304
|
+
if (/<th\b/i.test(rowHtml)) continue
|
|
305
|
+
const cells = Array.from(rowMatch[1].matchAll(/<td\b[^>]*>([\s\S]*?)<\/td>/gi))
|
|
306
|
+
.map((cell) => ({ raw: cell[1], text: cleanCell(cell[1]) }))
|
|
307
|
+
const termIndex = cells.findIndex((cell) => TERM_RE.test(cell.text))
|
|
308
|
+
const legacyShape = termIndex >= 0 && termIndex <= 1 && cells.length >= termIndex + 8
|
|
309
|
+
if (headerRecognized || legacyShape || /控制成绩显示/.test(rowHtml)) {
|
|
310
|
+
candidates.push({ html: rowHtml, cells, headerRecognized })
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
return candidates
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function scorePaginationEvidence(html, candidateRowCount) {
|
|
318
|
+
return paginationEvidence(html, candidateRowCount, '成绩')
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function classifySkippedScoreRow(rowHtml) {
|
|
322
|
+
const cleaned = stripSpaces(rowHtml)
|
|
323
|
+
const scoreMatch = cleaned.match(/<!--控制成绩显示--><tdstyle=.*?><ahref=.*?>(.*?)<\/a>/)
|
|
324
|
+
const scoreText = scoreMatch ? cleanCell(scoreMatch[1]) : ''
|
|
325
|
+
if (INVALID_SCORE_TEXTS.has(scoreText)) return 'score_not_published_placeholder'
|
|
326
|
+
if (!scoreMatch) return 'score_cell_unrecognized'
|
|
327
|
+
if (scoreText === '') return 'score_value_empty'
|
|
328
|
+
if (/^\d+(?:\.\d+)?$/.test(scoreText) && (Number(scoreText) < 0 || Number(scoreText) > 100)) {
|
|
329
|
+
return 'score_value_out_of_range'
|
|
330
|
+
}
|
|
331
|
+
const visible = cleanCell(rowHtml)
|
|
332
|
+
if (!TERM_RE.test((visible.match(/\d{4}-\d{4}-\d/) || [])[0] || '')) return 'term_unrecognized'
|
|
333
|
+
return 'record_structure_or_value_unparsed'
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** 兼容 metadata 入口;旧 parseScoresHtml 仍只返回 rows[]。 */
|
|
337
|
+
export function parseScoresHtmlWithMeta(html) {
|
|
338
|
+
const source = String(html || '')
|
|
339
|
+
const candidateRows = scoreCandidateRows(source).map((candidate) => candidate.html)
|
|
340
|
+
const rows = parseScoresRows(html)
|
|
341
|
+
const skippedReasons = {}
|
|
342
|
+
let parsedCandidates = 0
|
|
343
|
+
for (const candidate of candidateRows) {
|
|
344
|
+
try {
|
|
345
|
+
parsedCandidates += parseScoresRows(`<table>${candidate}</table>`).length
|
|
346
|
+
} catch {
|
|
347
|
+
const reason = classifySkippedScoreRow(candidate)
|
|
348
|
+
skippedReasons[reason] = (skippedReasons[reason] || 0) + 1
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
const skippedRowCount = Math.max(0, candidateRows.length - parsedCandidates)
|
|
352
|
+
const ignoredPlaceholderCount = Number(skippedReasons.score_not_published_placeholder || 0)
|
|
353
|
+
const unresolvedSkippedRowCount = Math.max(0, skippedRowCount - ignoredPlaceholderCount)
|
|
354
|
+
return {
|
|
355
|
+
rows,
|
|
356
|
+
meta: {
|
|
357
|
+
tableRecognized: candidateRows.length > 0,
|
|
358
|
+
candidateRowCount: candidateRows.length,
|
|
359
|
+
parsedRowCount: rows.length,
|
|
360
|
+
skippedRowCount,
|
|
361
|
+
unresolvedSkippedRowCount,
|
|
362
|
+
skippedReasons,
|
|
363
|
+
pagination: scorePaginationEvidence(html, candidateRows.length)
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
export function parseScoresHtml(html) {
|
|
369
|
+
return parseScoresHtmlWithMeta(html).rows
|
|
370
|
+
}
|
|
371
|
+
|
|
214
372
|
/**
|
|
215
373
|
* 解析学分修读页(/jsxsd/xxwcqk/xstxkxdqk.do,GET 直出)。
|
|
216
374
|
* 真实结构双表:
|
|
@@ -450,6 +608,50 @@ export function parseXjHtml(html, { full = false } = {}) {
|
|
|
450
608
|
return result
|
|
451
609
|
}
|
|
452
610
|
|
|
611
|
+
/** GPA 生产者范围摘要:以实际命中的表头、候选数据行和解析行生成。 */
|
|
612
|
+
export function parseGpaHtmlWithMeta(html) {
|
|
613
|
+
const table = findTableByHeaders(html, ['学号', '平均学分绩点'])
|
|
614
|
+
const data = parseGpaHtml(html)
|
|
615
|
+
const candidateRowCount = table ? table.rows.filter((row) => row.some((value) => String(value || '').trim())).length : 0
|
|
616
|
+
const skippedRowCount = Math.max(0, candidateRowCount - data.rows.length)
|
|
617
|
+
return {
|
|
618
|
+
data,
|
|
619
|
+
meta: {
|
|
620
|
+
tableRecognized: !!table,
|
|
621
|
+
candidateRowCount,
|
|
622
|
+
parsedRowCount: data.rows.length,
|
|
623
|
+
skippedRowCount,
|
|
624
|
+
unresolvedSkippedRowCount: skippedRowCount,
|
|
625
|
+
skippedReasons: skippedRowCount ? { empty_or_unrecognized_gpa_row: skippedRowCount } : {},
|
|
626
|
+
pagination: (() => {
|
|
627
|
+
const pagination = paginationEvidence(html, candidateRowCount, '平均学分绩点')
|
|
628
|
+
return pagination.detected
|
|
629
|
+
? pagination
|
|
630
|
+
: { ...pagination, state: 'not_applicable', reason: '平均学分绩点响应未显示分页控件或页数信息' }
|
|
631
|
+
})()
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/** 学籍生产者范围摘要:以实际识别的登录学生卡片和核心字段生成。 */
|
|
637
|
+
export function parseXjHtmlWithMeta(html, { full = false } = {}) {
|
|
638
|
+
const data = parseXjHtml(html, { full })
|
|
639
|
+
const labels = Array.from(String(html || '').matchAll(/(?:^|>)(学号|院系|专业|学制|班级|学习层次)[::]?/g)).map((m) => m[1])
|
|
640
|
+
return {
|
|
641
|
+
data,
|
|
642
|
+
meta: {
|
|
643
|
+
cardRecognized: !!data.studentId,
|
|
644
|
+
candidateRowCount: 1,
|
|
645
|
+
parsedRowCount: data.studentId ? 1 : 0,
|
|
646
|
+
skippedRowCount: 0,
|
|
647
|
+
unresolvedSkippedRowCount: 0,
|
|
648
|
+
skippedReasons: {},
|
|
649
|
+
recognizedLabels: [...new Set(labels)],
|
|
650
|
+
pagination: { detected: false, state: 'not_applicable', nextPageUrl: null, reason: '学籍端点返回当前登录学生单卡片' }
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
453
655
|
/**
|
|
454
656
|
* 解析培养执行计划页(/jsxsd/pyfa/pyfa_query,GET 直出)。
|
|
455
657
|
* 真实结构单表 11 列:序号/开课学期/课程编号/课程名称/开课单位/学分/
|
|
@@ -943,7 +1145,7 @@ function extractVNewsContent(html) {
|
|
|
943
1145
|
/**
|
|
944
1146
|
* 博达 CMS 通知详情页通用解析(T46 三源通吃)。
|
|
945
1147
|
* → { title, date, content, attachments: [{ name, url }] }
|
|
946
|
-
* - 标题:h1 优先,fallback title(剥站名后缀)
|
|
1148
|
+
* - 标题:h1/h2/h3 优先,fallback title(剥站名后缀)
|
|
947
1149
|
* - 日期:arttime(YY-MM-DD 补 20 前缀)/ 全页 YYYY-MM-DD / 发布时间元素
|
|
948
1150
|
* - 正文:.v_news_content 结构化文本(表格转 | 分列行)
|
|
949
1151
|
* - 附件:正文内 a[href 含 .doc/.pdf//system/_ 等] 列表化(不下载)
|
|
@@ -954,9 +1156,18 @@ export function parsePortalNoticeDetail(html, pageUrl = '') {
|
|
|
954
1156
|
throw parseError('通知详情正文(.v_news_content 容器缺失——页面可能已改版或非通知页)')
|
|
955
1157
|
}
|
|
956
1158
|
let title = ''
|
|
957
|
-
const
|
|
958
|
-
|
|
959
|
-
|
|
1159
|
+
const headingRe = /<h[123][^>]*>([\s\S]*?)<\/h[123]>/gi
|
|
1160
|
+
let heading
|
|
1161
|
+
let headingMatch
|
|
1162
|
+
while ((headingMatch = headingRe.exec(html))) {
|
|
1163
|
+
const candidate = decodeEntities(headingMatch[1].replace(/<[^>]+>/g, '').trim())
|
|
1164
|
+
if (candidate) {
|
|
1165
|
+
heading = candidate
|
|
1166
|
+
break
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
if (heading) {
|
|
1170
|
+
title = heading
|
|
960
1171
|
} else {
|
|
961
1172
|
const t = html.match(/<title>([^<]*)<\/title>/)
|
|
962
1173
|
title = t ? decodeEntities(t[1].replace(/-[^-]*$/, '').trim()) : ''
|
|
@@ -968,6 +1179,10 @@ export function parsePortalNoticeDetail(html, pageUrl = '') {
|
|
|
968
1179
|
const pub = html.match(/(?:发布时间|发布日期)[::\s]*(\d{4}-\d{2}-\d{2})/)
|
|
969
1180
|
if (pub) date = pub[1]
|
|
970
1181
|
}
|
|
1182
|
+
if (!date) {
|
|
1183
|
+
const pubCn = html.match(/(?:发布时间|发布日期)[::\s]*(\d{4})年\s*(\d{1,2})月\s*(\d{1,2})日/)
|
|
1184
|
+
if (pubCn) date = `${pubCn[1]}-${String(pubCn[2]).padStart(2, '0')}-${String(pubCn[3]).padStart(2, '0')}`
|
|
1185
|
+
}
|
|
971
1186
|
if (!date) date = (html.match(/\d{4}-\d{2}-\d{2}/) || [])[0] || ''
|
|
972
1187
|
|
|
973
1188
|
const inner = extractVNewsContent(html)
|
package/src/registry.js
CHANGED
|
@@ -27,7 +27,8 @@ export function initSdufe(env = nodeEnv()) {
|
|
|
27
27
|
* probeSession(cookie) → userInfo(过期抛 err.isJwLoginExpired)
|
|
28
28
|
* fetchCurrentTerm(cookie) → string | null
|
|
29
29
|
* fetchSchedule(cookie, { term, week }) → { weeks, remark? }
|
|
30
|
-
* fetchScores(cookie, { term }) → rows[]
|
|
30
|
+
* fetchScores(cookie, { term }) → rows[](兼容);fetchScoresWithMeta → {data,meta}
|
|
31
|
+
* fetchGpa/fetchXj 保持旧返回;对应 WithMeta 方法返回读取范围与字段证明
|
|
31
32
|
*/
|
|
32
33
|
export function getAdapter(schoolId) {
|
|
33
34
|
const adapter = adapters.get(schoolId)
|