linke-sdufe 0.1.0 → 0.2.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.1.0",
3
+ "version": "0.2.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
@@ -32,12 +32,25 @@ import {
32
32
  parseProgressDetailHtml,
33
33
  parseSimpleTable,
34
34
  parseJwcNotices,
35
+ parsePortalNotices,
36
+ parsePortalNoticeDetail,
35
37
  parseMakeupsHtml,
36
38
  } from './parsers.js'
37
39
 
38
40
  const USER_AGENT = 'Apifox/1.0.0 (https://apifox.com)'
39
41
  const REQUEST_TIMEOUT_MS = 20000
40
42
 
43
+ /**
44
+ * 校园官网公开通知三大主源(T46;博达 CMS 同构,勘察实锤 2026-09-04)。
45
+ * 翻页:listPath=栏目首页,page N → pagePath/{N-1}.htm。
46
+ * 学工部通知栏目实测=info/1093(数据源地图旧记 1090 实测 404)。
47
+ */
48
+ export const PORTAL_NOTICE_SOURCES = {
49
+ jwc: { name: '教务处官网', origin: 'https://jwc.sdufe.edu.cn', listPath: '/zxdt/tzgg.htm', pagePath: '/zxdt/tzgg', infoPath: '/info/1043/' },
50
+ main: { name: '学校主站', origin: 'https://www.sdufe.edu.cn', listPath: '/index/tzgg.htm', pagePath: '/index/tzgg', infoPath: '/info/1068/' },
51
+ xgb: { name: '学工部官网', origin: 'https://student.sdufe.edu.cn', listPath: '/index/tzgg.htm', pagePath: '/index/tzgg', infoPath: '/info/1093/' },
52
+ }
53
+
41
54
  /** 课程属性中文名 → zzdKcSX 表单码(强智 kbxx_kc_ifr 口径) */
42
55
  export const COURSE_TYPE_MAP = {
43
56
  必修: '1',
@@ -386,24 +399,82 @@ export function createSdufeAdapter(env) {
386
399
 
387
400
  /**
388
401
  * jwc.sdufe.edu.cn 通知公告(公开源,无需登录;T17 双源之一)。
402
+ * T46 起委托三源通用 fetchPortalNotices(行为不变)。
389
403
  * 礼貌纪律:只抓请求的页(分页路径 tzgg/2.htm…),不预取整站。
390
404
  */
391
405
  async fetchJwcNotices(page = 1) {
392
- const path = page <= 1 ? '/zxdt/tzgg.htm' : `/zxdt/tzgg/${page - 1}.htm`
406
+ return this.fetchPortalNotices('jwc', page)
407
+ },
408
+
409
+ /**
410
+ * 校园官网公开通知三大主源列表(T46;博达 CMS 同构一套解析)。
411
+ * site ∈ PORTAL_NOTICE_SOURCES:jwc 教务处 / main 主站 / xgb 学工部。
412
+ * 翻页口径(逐源实证):page 1=栏目首页 tzgg.htm,page N= tzgg/{N-1}.htm。
413
+ * 礼貌纪律:只抓请求的页,不做归档批量预取。
414
+ */
415
+ async fetchPortalNotices(site, page = 1) {
416
+ const src = PORTAL_NOTICE_SOURCES[site]
417
+ if (!src) {
418
+ throw new LinkeError('BAD_SOURCE', `未知通知源:${site}(限 ${Object.keys(PORTAL_NOTICE_SOURCES).join('/')})`, {
419
+ exitCode: EXIT.GENERAL,
420
+ hint: 'source 取 jwc=教务处 / main=学校主站 / xgb=学工部',
421
+ })
422
+ }
423
+ const path = page <= 1 ? src.listPath : `${src.pagePath}/${page - 1}.htm`
424
+ let response
425
+ try {
426
+ response = await env.fetch(src.origin + path, {
427
+ headers: { 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) linke-cli' },
428
+ redirect: 'follow',
429
+ timeoutMs: 15000,
430
+ })
431
+ } catch (err) {
432
+ throw networkError(`抓取${src.name}公告页`, err)
433
+ }
434
+ if (!response.ok) {
435
+ throw networkError(`抓取${src.name}公告页(HTTP ${response.status})`, null)
436
+ }
437
+ // 栏目过滤:列表页含导航/侧栏的 info 链接(如主站「学校概况」),
438
+ // 只保留本源通知栏目条目
439
+ return parsePortalNotices(await response.text(), src.origin)
440
+ .filter((x) => !src.infoPath || x.url.includes(src.infoPath))
441
+ },
442
+
443
+ /**
444
+ * 校园官网公开通知详情全文(T46)。url 限三源域名(白名单防抓任意站);
445
+ * 礼貌纪律:只抓用户点名的这一篇,不做批量预取。
446
+ */
447
+ async fetchPortalNoticeDetail(url) {
448
+ let parsed
449
+ try {
450
+ parsed = new URL(url)
451
+ } catch {
452
+ throw new LinkeError('BAD_URL', `通知详情 URL 无效:${url}`, {
453
+ exitCode: EXIT.GENERAL,
454
+ hint: 'url 从 notices / get_notices 列表结果的 url 字段取(如 https://jwc.sdufe.edu.cn/info/1043/5995.htm)',
455
+ })
456
+ }
457
+ const src = Object.values(PORTAL_NOTICE_SOURCES).find((s) => s.origin === parsed.origin)
458
+ if (!src || !/^\/info\/\d+\/\d+\.htm$/.test(parsed.pathname)) {
459
+ throw new LinkeError('BAD_URL', `URL 不在校内通知三源范围:${url}`, {
460
+ exitCode: EXIT.GENERAL,
461
+ hint: `仅支持 ${Object.values(PORTAL_NOTICE_SOURCES).map((s) => s.origin).join(' / ')} 的 info/{栏目}/{id}.htm 详情页`,
462
+ })
463
+ }
393
464
  let response
394
465
  try {
395
- response = await env.fetch('https://jwc.sdufe.edu.cn' + path, {
466
+ response = await env.fetch(url, {
396
467
  headers: { 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) linke-cli' },
397
468
  redirect: 'follow',
398
469
  timeoutMs: 15000,
399
470
  })
400
471
  } catch (err) {
401
- throw networkError('抓取教务处公告页', err)
472
+ throw networkError(`抓取${src.name}通知详情`, err)
402
473
  }
403
474
  if (!response.ok) {
404
- throw networkError(`抓取教务处公告页(HTTP ${response.status})`, null)
475
+ throw networkError(`抓取${src.name}通知详情(HTTP ${response.status})`, null)
405
476
  }
406
- return parseJwcNotices(await response.text())
477
+ return parsePortalNoticeDetail(await response.text(), url)
407
478
  },
408
479
 
409
480
  /** 教务系统「已收公告」(个人收件,登录态;T17 双源之二) */
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 } from './adapter.js'
5
+ export { createSdufeAdapter, COURSE_TYPE_MAP, PORTAL_NOTICE_SOURCES } 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
@@ -771,27 +771,11 @@ export function parseSimpleTable(html, { emptyText = '未查询到数据', dropF
771
771
 
772
772
  /**
773
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 }]
774
+ * T46 起委托通用博达列表解析 parsePortalNotices(签名/输出不变,
775
+ * 原地正则实现退役——三源一套解析)。
779
776
  */
780
777
  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
778
+ return parsePortalNotices(html, baseUrl)
795
779
  }
796
780
 
797
781
  /**
@@ -823,3 +807,176 @@ export function parseMakeupsHtml(html) {
823
807
  return { makeups: [], note: '暂无补考记录' }
824
808
  }
825
809
  }
810
+
811
+ // ---------- 校园官网公开通知(博达 CMS 三源通用,T46) ----------
812
+ // 三源(教务处 jwc / 主站 main / 学工部 xgb)同构:列表条目
813
+ // <a href="../info/{栏}/{id}.htm">,详情正文容器 .v_news_content。
814
+ // 勘察实锤(2026-09-04 curl 逐源实证):学工部通知栏目=info/1093
815
+ // (数据源地图旧记 1090 实测 404,以本实现为准回灌)。
816
+
817
+ /** 常见 HTML 实体解码(通知正文范围,不追求全表) */
818
+ function decodeEntities(text) {
819
+ return String(text)
820
+ .replace(/&nbsp;/g, ' ')
821
+ .replace(/&amp;/g, '&')
822
+ .replace(/&lt;/g, '<')
823
+ .replace(/&gt;/g, '>').replace(/&quot;/g, '"')
824
+ .replace(/&#39;|&apos;/g, "'")
825
+ .replace(/&ldquo;|&#8220;/g, '“')
826
+ .replace(/&rdquo;|&#8221;/g, '”')
827
+ .replace(/&mdash;/g, '—')
828
+ .replace(/&hellip;/g, '…')
829
+ .replace(/&middot;/g, '·')
830
+ .replace(/&#(\d+);/g, (_, n) => {
831
+ const code = Number(n)
832
+ return code > 0 && code < 0x10ffff ? String.fromCodePoint(code) : ''
833
+ })
834
+ }
835
+
836
+ /** 去标签取纯文本;表格块整体抽出按行转「| 分列」行(td 内嵌 p 等不误断行) */
837
+ function htmlToText(html) {
838
+ let s = String(html)
839
+ s = s.replace(/<(script|style)[\s\S]*?<\/\1>/gi, '')
840
+ const tables = []
841
+ s = s.replace(/<table[\s\S]*?<\/table>/gi, (tableHtml) => {
842
+ const rows = tableHtml.match(/<tr[\s\S]*?<\/tr>/gi) || []
843
+ const lines = rows
844
+ .map((row) =>
845
+ (row.match(/<t[dh][^>]*>[\s\S]*?<\/t[dh]>/gi) || [])
846
+ .map((c) => decodeEntities(c.replace(/<[^>]+>/g, '')).replace(/\s+/g, ' ').trim())
847
+ .join(' | ')
848
+ .replace(/^[\s|]+|[\s|]+$/g, '')
849
+ )
850
+ .filter(Boolean)
851
+ tables.push(lines.join('\n'))
852
+ return `\u0000T${tables.length - 1}\u0000`
853
+ })
854
+ s = s.replace(/<br\s*\/?>/gi, '\n')
855
+ s = s.replace(/<\/(p|div|h[1-6]|li|tr|table)>/gi, '\n')
856
+ s = s.replace(/<[^>]+>/g, '')
857
+ s = decodeEntities(s)
858
+ s = s.replace(/[ \t\u3000]+/g, ' ')
859
+ s = s.replace(/\n\s*\n+/g, '\n')
860
+ s = s
861
+ .split('\n')
862
+ .map((l) => l.trim())
863
+ .filter(Boolean)
864
+ .join('\n')
865
+ return s.replace(/\u0000T(\d+)\u0000/g, (_, n) => tables[Number(n)] || '')
866
+ }
867
+
868
+ /**
869
+ * 博达 CMS 通知列表通用解析(T46 三源通吃)。
870
+ * 兼容形态(逐源实证):
871
+ * - 教务处/主站:日期在 </a> 后窗口(2026-09-04 / [2026-09-04])
872
+ * - 学工部:日期在条目 <div class="time"> 内 day+month 分元素(条目 a 之前);
873
+ * 同一条目 h3 标题 a 与 des 摘要 a 同 URL → 按 URL 去重保首个(标题)
874
+ * → [{ title, url, date }](url 绝对地址)
875
+ */
876
+ export function parsePortalNotices(html, baseUrl = '') {
877
+ const list = []
878
+ const seen = new Set()
879
+ const linkRe = /<a\s+href="([^"]*\/info\/\d+\/\d+\.htm)"[^>]*>([^<]+)<\/a>/g
880
+ let m
881
+ while ((m = linkRe.exec(html))) {
882
+ let url = m[1]
883
+ if (!/^https?:/i.test(url)) {
884
+ // "../info/x" 相对列表页目录 → 站点根 /info/x(博达栏目均为根级 info/)
885
+ url = baseUrl.replace(/\/$/, '') + '/' + url.replace(/^(\.\.\/)+/, '')
886
+ }
887
+ if (seen.has(url)) continue
888
+ seen.add(url)
889
+ const after = html.slice(m.index + m[0].length, m.index + m[0].length + 600)
890
+ const before = html.slice(Math.max(0, m.index - 600), m.index)
891
+ let date = (after.match(/\d{4}-\d{2}-\d{2}/) || [])[0] || ''
892
+ if (!date) {
893
+ // 学工部形态:<p class="day">13</p><p class="month">2026-07</p>
894
+ const dm = before.match(/class="day">\s*(\d{1,2})\s*<\/p>\s*<p class="month">\s*(\d{4}-\d{2})/)
895
+ if (dm) date = `${dm[2]}-${dm[1].padStart(2, '0')}`
896
+ }
897
+ list.push({ title: decodeEntities(m[2].trim()), url, date })
898
+ }
899
+ return list
900
+ }
901
+
902
+ /**
903
+ * 定位详情正文容器 .v_news_content 并按 div 平衡提取其 innerHTML
904
+ * (主站页含 2 个 v_news_content——首个内嵌 script 取空的取非空者)。
905
+ * 找不到 → null(调用方决定报错口径:明确报错不空转,T46 验收 3)。
906
+ */
907
+ function extractVNewsContent(html) {
908
+ const openTagRe = /<div[^>]*class="[^"]*v_news_content[^"]*"[^>]*>/gi
909
+ let om
910
+ while ((om = openTagRe.exec(html))) {
911
+ const from = om.index + om[0].length
912
+ let depth = 1
913
+ const tagRe = /<\/div\s*>|<div\b[^>]*>/gi
914
+ tagRe.lastIndex = from
915
+ let t
916
+ while ((t = tagRe.exec(html))) {
917
+ if (t[0][1] === '/') depth--
918
+ else depth++
919
+ if (depth === 0) {
920
+ const inner = html.slice(from, t.index)
921
+ // 首个容器可能为空壳(script 注入型),取下一个非空者
922
+ if (htmlToText(inner).length > 0) return inner
923
+ break
924
+ }
925
+ }
926
+ }
927
+ return null
928
+ }
929
+
930
+ /**
931
+ * 博达 CMS 通知详情页通用解析(T46 三源通吃)。
932
+ * → { title, date, content, attachments: [{ name, url }] }
933
+ * - 标题:h1 优先,fallback title(剥站名后缀)
934
+ * - 日期:arttime(YY-MM-DD 补 20 前缀)/ 全页 YYYY-MM-DD / 发布时间元素
935
+ * - 正文:.v_news_content 结构化文本(表格转 | 分列行)
936
+ * - 附件:正文内 a[href 含 .doc/.pdf//system/_ 等] 列表化(不下载)
937
+ * 容器缺失抛 parseError(改版/非通知页明确报错,验收 3 口径)。
938
+ */
939
+ export function parsePortalNoticeDetail(html, pageUrl = '') {
940
+ if (!html || typeof html !== 'string' || !extractVNewsContent(html)) {
941
+ throw parseError('通知详情正文(.v_news_content 容器缺失——页面可能已改版或非通知页)')
942
+ }
943
+ let title = ''
944
+ const h1 = html.match(/<h1[^>]*>([\s\S]*?)<\/h1>/)
945
+ if (h1) {
946
+ title = decodeEntities(h1[1].replace(/<[^>]+>/g, '').trim())
947
+ } else {
948
+ const t = html.match(/<title>([^<]*)<\/title>/)
949
+ title = t ? decodeEntities(t[1].replace(/-[^-]*$/, '').trim()) : ''
950
+ }
951
+ let date = ''
952
+ const arttime = html.match(/class="arttime"[\s\S]{0,200}?(\d{2})-(\d{2})-(\d{2})/)
953
+ if (arttime) date = `20${arttime[1]}-${arttime[2]}-${arttime[3]}`
954
+ if (!date) {
955
+ const pub = html.match(/(?:发布时间|发布日期)[::\s]*(\d{4}-\d{2}-\d{2})/)
956
+ if (pub) date = pub[1]
957
+ }
958
+ if (!date) date = (html.match(/\d{4}-\d{2}-\d{2}/) || [])[0] || ''
959
+
960
+ const inner = extractVNewsContent(html)
961
+ const content = htmlToText(inner)
962
+
963
+ const attachments = []
964
+ const attRe = /<a\s+href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/g
965
+ let a
966
+ const origin = (() => {
967
+ try { return new URL(pageUrl).origin } catch { return '' }
968
+ })()
969
+ while ((a = attRe.exec(inner))) {
970
+ const href = a[1]
971
+ const isAtt = /\.(docx?|xlsx?|pptx?|pdf|zip|rar|txt)(\?|$)/i.test(href) || href.includes('/system/_')
972
+ if (!isAtt) continue
973
+ let url = href
974
+ if (!/^https?:/i.test(url) && origin) {
975
+ url = origin + (url.startsWith('/') ? '' : '/') + url.replace(/^(\.\.\/)+/, '')
976
+ url = url.replace(/([^:])\/+/g, '$1/')
977
+ }
978
+ const name = decodeEntities(a[2].replace(/<[^>]+>/g, '').trim()) || decodeEntities((href.split('wbfileid=')[1] || '').slice(0, 8)) || '附件'
979
+ attachments.push({ name, url })
980
+ }
981
+ return { title, date, content, attachments }
982
+ }