linke-sdufe 0.3.1 → 0.5.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "linke-sdufe",
3
- "version": "0.3.1",
3
+ "version": "0.5.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
@@ -34,6 +34,7 @@ import {
34
34
  parseJwcNotices,
35
35
  parsePortalNotices,
36
36
  parsePortalNoticeDetail,
37
+ parsePortalPage,
37
38
  parseMakeupsHtml,
38
39
  } from './parsers.js'
39
40
 
@@ -96,6 +97,47 @@ export const PORTAL_NOTICE_SOURCES = {
96
97
  sclx: { name: '出国留学培训基地', origin: 'https://sclx.sdufe.edu.cn', listPath: '/index/tzgg.htm', pagePath: '/index/tzgg', infoPaths: ['1078'] },
97
98
  }
98
99
 
100
+ /**
101
+ * 校园官网明面具名页面注册表(T46 续四;学校概况类栏目页/单页——
102
+ * 非标准博达文章容器,解析走 parsePortalPage 容器链)。
103
+ * 逐页实勘 2026-09-04:全部含可提取正文。
104
+ */
105
+ export const PORTAL_PAGES = {
106
+ xxjj: { name: '学校简介', url: 'https://www.sdufe.edu.cn/xxgk_/xxjj.htm' },
107
+ xrld: { name: '现任领导', url: 'https://www.sdufe.edu.cn/xxgk_/xrld.htm' },
108
+ zuzjg: { name: '组织机构', url: 'https://www.sdufe.edu.cn/zuzjg.htm' },
109
+ xqbc: { name: '校车班线', url: 'https://www.sdufe.edu.cn/xyfw/xqbc.htm' },
110
+ xydh: { name: '校园电话', url: 'https://www.sdufe.edu.cn/xyfw/xydh.htm' },
111
+ zxxl: { name: '校历', url: 'https://www.sdufe.edu.cn/xyfw/zxxl.htm' },
112
+ // T47 学院简介/领导页(16 站 24 key,照单注册清单 docs/plan/crawl/t47-page-key-registry.md
113
+ // ——地图 v9+批次 8 全量遍历双向验证;命名 {学院缩写}-xyjj=学院简介 / -ldr=学院领导)。
114
+ // 领导页为表格版式(姓名/职务/分工三列)无标准博达容器,走 parsePortalPage body 兜底。
115
+ 'gkgc-xyjj': { name: '管理科学与工程学院简介', url: 'https://gkgc.sdufe.edu.cn/xygk/xyjj.htm' },
116
+ 'gkgc-ldr': { name: '管理科学与工程学院领导', url: 'https://gkgc.sdufe.edu.cn/xygk/xyld.htm' },
117
+ 'cs-xyjj': { name: '计算机与人工智能学院简介', url: 'https://cs.sdufe.edu.cn/xygk/xyjj.htm' },
118
+ 'cs-ldr': { name: '计算机与人工智能学院领导', url: 'https://cs.sdufe.edu.cn/xygk/xyld.htm' },
119
+ 'spft-xyjj': { name: '财政税务学院简介', url: 'https://spft.sdufe.edu.cn/xygk/xyjj.htm' },
120
+ 'spft-ldr': { name: '财政税务学院领导', url: 'https://spft.sdufe.edu.cn/xygk/xyld.htm' },
121
+ 'sie-xyjj': { name: '国际教育学院简介', url: 'https://sie.sdufe.edu.cn/xygk/xyjj.htm' },
122
+ 'sie-ldr': { name: '国际教育学院领导', url: 'https://sie.sdufe.edu.cn/xygk/xyld.htm' },
123
+ 'ys-xyjj': { name: '燕山学院简介', url: 'https://ys.sdufe.edu.cn/xygk/xyjj.htm' },
124
+ 'ys-ldr': { name: '燕山学院领导', url: 'https://ys.sdufe.edu.cn/xygk/xyld.htm' },
125
+ 'insurance-xyjj': { name: '保险学院简介', url: 'https://insurance.sdufe.edu.cn/xygk/xyjj.htm' },
126
+ 'insurance-ldr': { name: '保险学院领导', url: 'https://insurance.sdufe.edu.cn/xygk/xrld.htm' },
127
+ 'jinrong-xyjj': { name: '金融学院简介', url: 'https://jinrong.sdufe.edu.cn/xygk.htm' },
128
+ 'jinrong-ldr': { name: '金融学院领导', url: 'https://jinrong.sdufe.edu.cn/xygk/xrld.htm' },
129
+ 'marx-xyjj': { name: '马克思主义学院简介', url: 'https://marx.sdufe.edu.cn/index/xygk/xyjj.htm' },
130
+ 'marx-ldr': { name: '马克思主义学院领导', url: 'https://marx.sdufe.edu.cn/index/xygk/xrld.htm' },
131
+ 'sam-ldr': { name: '统计与数学学院领导', url: 'https://sam.sdufe.edu.cn/xygk/xrld.htm' },
132
+ 'bcf-ldr': { name: '区块链金融实验室领导', url: 'https://bcf.sdufe.edu.cn/sysgk1/xrld.htm' },
133
+ 'mpa-ldr': { name: 'MPA中心领导', url: 'https://mpa.sdufe.edu.cn/zxgk/xrld.htm' },
134
+ 'law-xyjj': { name: '法学院简介', url: 'https://law.sdufe.edu.cn/xygk1/xyjj.htm' },
135
+ 'gsglxy-xyjj': { name: '工商管理学院简介', url: 'https://gsglxy.sdufe.edu.cn/xygk1/xyjj.htm' },
136
+ 'sce-xyjj': { name: '继续教育学院简介', url: 'https://sce.sdufe.edu.cn/xygk1/xyjj.htm' },
137
+ 'site-xyjj': { name: '国际经贸学院简介', url: 'https://site.sdufe.edu.cn/xygk1/xyjj.htm' },
138
+ 'mba-xyjj': { name: 'MBA学院简介', url: 'https://mba.sdufe.edu.cn/xygk/xyjj.htm' },
139
+ }
140
+
99
141
  /** 课程属性中文名 → zzdKcSX 表单码(强智 kbxx_kc_ifr 口径) */
100
142
  export const COURSE_TYPE_MAP = {
101
143
  必修: '1',
@@ -492,6 +534,41 @@ export function createSdufeAdapter(env) {
492
534
  .filter((x) => !src.infoPaths || !src.infoPaths.length || src.infoPaths.some((c) => x.url.includes(`/info/${c}/`)))
493
535
  },
494
536
 
537
+ /**
538
+ * 校园官网明面具名页面抓取(T46 续四)。
539
+ * 返回 { title, content, links };links=页面内 info 文章链接清单
540
+ * (如现任领导页→每位领导详情页),供下一步详情深入。
541
+ */
542
+ async fetchPortalPage(pageKey) {
543
+ const page = PORTAL_PAGES[pageKey]
544
+ if (!page) {
545
+ throw new LinkeError('BAD_PAGE', `未知具名页面:${pageKey}(可选:${Object.keys(PORTAL_PAGES).join('/')})`, {
546
+ exitCode: EXIT.GENERAL,
547
+ hint: '学校级:xxjj=学校简介 / xrld=现任领导 / zuzjg=组织机构 / xqbc=校车班线 / xydh=校园电话 / zxxl=校历;学院级(T47):{学院缩写}-xyjj=学院简介 / {学院缩写}-ldr=学院领导(如 gkgc-ldr=管科学院领导)',
548
+ })
549
+ }
550
+ let response
551
+ try {
552
+ response = await env.fetch(page.url, {
553
+ headers: { 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) linke-cli' },
554
+ redirect: 'follow',
555
+ timeoutMs: 15000,
556
+ })
557
+ } catch (err) {
558
+ throw networkError(`抓取${page.name}页面`, err)
559
+ }
560
+ if (!response.ok) {
561
+ throw networkError(`抓取${page.name}页面(HTTP ${response.status})`, null)
562
+ }
563
+ const parsed = parsePortalPage(await response.text(), page.url)
564
+ if (!parsed) {
565
+ throw new LinkeError('PARSE', `解析${page.name}页面失败(页面结构可能已变化)`, {
566
+ exitCode: EXIT.PARSE,
567
+ })
568
+ }
569
+ return { page: pageKey, name: page.name, url: page.url, ...parsed }
570
+ },
571
+
495
572
  /**
496
573
  * 校园官网公开通知详情全文(T46)。url 限源注册表内域名(白名单防抓任意站);
497
574
  * 礼貌纪律:只抓用户点名的这一篇,不做批量预取。
package/src/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
  * linke-sdufe:山财强智教务适配器共享包。
3
3
  * linke-cli(Node)与 Linke App(uni-app,经 uni.request 垫片)同源引用。
4
4
  */
5
- export { createSdufeAdapter, COURSE_TYPE_MAP, PORTAL_NOTICE_SOURCES } from './adapter.js'
5
+ export { createSdufeAdapter, COURSE_TYPE_MAP, PORTAL_NOTICE_SOURCES, PORTAL_PAGES } from './adapter.js'
6
6
  export { nodeEnv, validateEnv } from './env.js'
7
7
  export { computeEncoded } from './encoding.js'
8
8
  export * as parsers from './parsers.js'
package/src/parsers.js CHANGED
@@ -993,3 +993,81 @@ export function parsePortalNoticeDetail(html, pageUrl = '') {
993
993
  }
994
994
  return { title, date, content, attachments }
995
995
  }
996
+
997
+ /** 校园官网明面具名页面解析(T46 续四):容器链兜底——v_news_content →
998
+ * #vsb_content(含序号后缀族,取最外层;T47 学院站实勘)→ .wp_articlecontent /
999
+ * .c_content → body 主文本(学校概况类页面非标准博达
1000
+ * 文章容器,逐页实勘判例:xxjj 学校简介/xrld 现任领导/xydh 校园电话等)。
1001
+ * 同时提取页面内 info 文章链接清单(如现任领导页→每位领导详情页),
1002
+ * 供「列表→详情」动线延续。找不到任何容器时回退 body 去头尾。 */
1003
+ export function parsePortalPage(html, pageUrl = '') {
1004
+ if (!html || typeof html !== 'string') return null
1005
+ let inner = null
1006
+ // 1. 标准博达容器优先(复用详情提取:v_news_content 含空壳跳过逻辑)
1007
+ inner = extractVNewsContent(html)
1008
+ // 2. vsb_content / wp_articlecontent / c_content 容器(单次 div 平衡)。
1009
+ // T47:学院站 vsb_content 常带序号后缀(vsb_content_4、vsb_content_2…),
1010
+ // 且内层 u 系 div 的 id 也以 vsb_content 开头——取 id 最短者=最外层容器
1011
+ // (law/sce/site/gsglxy/mba 实勘),不再只认精确 id="vsb_content"。
1012
+ if (!inner) {
1013
+ const candidates = []
1014
+ const vsbRe = /id="(vsb_content[^"]*)"/gi
1015
+ let vm
1016
+ let vsbBest = null
1017
+ while ((vm = vsbRe.exec(html))) {
1018
+ if (!vsbBest || vm[1].length < vsbBest[1].length) vsbBest = vm
1019
+ }
1020
+ if (vsbBest) candidates.push(vsbBest.index)
1021
+ for (const marker of [/class="[^"]*wp_articlecontent[^"]*"/i, /class="[^"]*c_content[^"]*"/i]) {
1022
+ const om = html.match(marker)
1023
+ if (om) candidates.push(om.index)
1024
+ }
1025
+ for (const idx of candidates) {
1026
+ const openTag = html.lastIndexOf('<div', idx)
1027
+ if (openTag === -1) continue
1028
+ const openEnd = html.indexOf('>', openTag)
1029
+ if (openEnd === -1) continue
1030
+ let depth = 1
1031
+ const tagRe = /<\/div\s*>|<div\b[^>]*>/gi
1032
+ tagRe.lastIndex = openEnd + 1
1033
+ let t
1034
+ while ((t = tagRe.exec(html))) {
1035
+ if (t[0][1] === '/') depth--
1036
+ else depth++
1037
+ if (depth === 0) {
1038
+ inner = html.slice(openEnd + 1, t.index)
1039
+ break
1040
+ }
1041
+ }
1042
+ if (inner) break
1043
+ }
1044
+ }
1045
+ // 3. 兜底:body 去头尾(script/style 已在 htmlToText 剔除)
1046
+ const bodyMatch = inner ? null : html.match(/<body[^>]*>([\s\S]*?)<\/body>/i)
1047
+ const source = inner || (bodyMatch ? bodyMatch[1] : html)
1048
+ const content = htmlToText(source)
1049
+ if (!content) return null
1050
+ // 页面内 info 文章链接清单(去重、绝对化)
1051
+ const origin = (() => {
1052
+ try { return new URL(pageUrl).origin } catch { return '' }
1053
+ })()
1054
+ const links = []
1055
+ const seen = new Set()
1056
+ const linkRe = /<a\s[^>]*?href="((?:[^"]*?\/)?info\/\d+\/\d+\.htm)"[^>]*>([\s\S]*?)<\/a>/g
1057
+ let a
1058
+ while ((a = linkRe.exec(source))) {
1059
+ let url = a[1]
1060
+ if (!/^https?:/i.test(url) && origin) {
1061
+ url = origin + '/' + url.replace(/^(\.\.\/)+/, '').replace(/^\//, '')
1062
+ url = url.replace(/([^:])\/+/g, '$1/')
1063
+ }
1064
+ if (seen.has(url)) continue
1065
+ seen.add(url)
1066
+ const title = decodeEntities(a[2].replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim())
1067
+ if (title) links.push({ title: title.slice(0, 80), url })
1068
+ }
1069
+ // 标题:页面 title 剥站名后缀
1070
+ const t = html.match(/<title>([^<]*)<\/title>/)
1071
+ const pageTitle = t ? decodeEntities(t[1].replace(/-[^-]*$/, '').trim()) : ''
1072
+ return { title: pageTitle, content, links }
1073
+ }