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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "linke-sdufe",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "山东财经大学强智教务(jw.sdufe.edu.cn)适配器共享包:登录、教务查询与页面解析。linke-cli 与 Linke App 同源引用;教务请求一律从用户设备直发。",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
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*条(?:&nbsp;|\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(/&nbsp;/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
- * 校园官网公开通知三大主源(T46;博达 CMS 同构,勘察实锤 2026-09-04)。
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
- * 翻页:listPath 去掉 .htm 即 pagerPath(page N → {pagerPath}/{N-1}.htm);
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
- /** 抓成绩:term 空 = 全部学期 */
335
- async fetchScores(cookie, { term = '' } = {}) {
501
+ /** 抓成绩及范围证明:term 空 = 全部学期;旧 fetchScores 仍只返回 rows[]。 */
502
+ async fetchScoresWithMeta(cookie, { term = '' } = {}) {
336
503
  const form = `kksj=${encodeURIComponent(term)}&xsfs=all`
337
- const res = await this.request(`${this.baseUrl}/jsxsd/kscj/cjcx_list`, 'POST', {
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
- return parseScoresHtml(res.text || '')
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
- /** 平均学分绩点(含辅修行,GET 直出) */
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
- const res = await this.request(`${this.baseUrl}/jsxsd/kscj/cjcx_avg`, 'GET', { cookie })
376
- return parseGpaHtml(res.text || '')
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
- const res = await this.request(`${this.baseUrl}/jsxsd/grxx/xsxx`, 'GET', { cookie })
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 三源起步,T46 扩展 36 源;博达 CMS 同构
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 path = page <= 1 ? src.listPath : `${src.pagePath}/${page - 1}.htm`
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(src.origin + path, {
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
- return parsePortalNotices(await response.text(), src.origin)
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
- export function parseScoresHtml(html) {
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><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
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 h1 = html.match(/<h1[^>]*>([\s\S]*?)<\/h1>/)
958
- if (h1) {
959
- title = decodeEntities(h1[1].replace(/<[^>]+>/g, '').trim())
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)