linke-sdufe 0.1.0 → 0.3.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.3.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,67 @@ 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
+ /**
49
+ * 校园官网公开通知源注册表(T46 起 36 源;数据源地图 v4 全站探测 +
50
+ * 2026-09-04 执行者逐站实勘:列表/栏目/翻页/详情容器逐站 curl 实证)。
51
+ * 翻页:listPath 去掉 .htm 即 pagerPath(page N → {pagerPath}/{N-1}.htm);
52
+ * pagePath 为空=该源翻页形态特殊未实证(如 jinrong),page>1 明确报错。
53
+ * infoPaths:列表条目栏目过滤(防导航混入);详情白名单按 origin(不限栏目)。
54
+ * 不接:zgjjyjy(无通知栏目)、xcb(内容发新闻网)、tuanwei/grads 等
55
+ * 防护壳与登录系统(数据源地图标注排除)。
56
+ */
57
+ export const PORTAL_NOTICE_SOURCES = {
58
+ jwc: { name: '教务处官网', origin: 'https://jwc.sdufe.edu.cn', listPath: '/zxdt/tzgg.htm', pagePath: '/zxdt/tzgg', infoPaths: ['1043'] },
59
+ main: { name: '学校主站', origin: 'https://www.sdufe.edu.cn', listPath: '/index/tzgg.htm', pagePath: '/index/tzgg', infoPaths: ['1068'] },
60
+ xgb: { name: '学工部官网', origin: 'https://student.sdufe.edu.cn', listPath: '/index/tzgg.htm', pagePath: '/index/tzgg', infoPaths: ['1093'] },
61
+ archives: { name: '档案馆', origin: 'https://archives.sdufe.edu.cn', listPath: '/index/tzgg.htm', pagePath: '/index/tzgg', infoPaths: ['1053'] },
62
+ bcf: { name: '区块链金融实验室', origin: 'https://bcf.sdufe.edu.cn', listPath: '/xwzx/zhxw.htm', pagePath: '/xwzx/zhxw', infoPaths: ['1072'] },
63
+ cs: { name: '计算机学院', origin: 'https://cs.sdufe.edu.cn', listPath: '/index/tzgg.htm', pagePath: '/index/tzgg', infoPaths: ['1014'] },
64
+ gkgc: { name: '管科学院', origin: 'https://gkgc.sdufe.edu.cn', listPath: '/tzgg.htm', pagePath: '/tzgg', infoPaths: ['1099'] },
65
+ gonghui: { name: '工会', origin: 'https://gonghui.sdufe.edu.cn', listPath: '/index/tzgg.htm', pagePath: '/index/tzgg', infoPaths: ['1061'] },
66
+ gsglxy: { name: '工商管理学院', origin: 'https://gsglxy.sdufe.edu.cn', listPath: '/index/tzgg.htm', pagePath: '/index/tzgg', infoPaths: ['1193'] },
67
+ hf: { name: '合作发展', origin: 'https://hf.sdufe.edu.cn', listPath: '/fxljy1/tzgg.htm', pagePath: '/fxljy1/tzgg', infoPaths: ['1194'] },
68
+ insurance: { name: '保险学院', origin: 'https://insurance.sdufe.edu.cn', listPath: '/tzgg.htm', pagePath: '/tzgg', infoPaths: ['1059', '1152'] },
69
+ jinrong: { name: '金融学院', origin: 'https://jinrong.sdufe.edu.cn', listPath: '/tzgg.htm', pagePath: '', infoPaths: ['1043', '1160'] },
70
+ jiwei: { name: '纪委', origin: 'https://jiwei.sdufe.edu.cn', listPath: '/tzgg.htm', pagePath: '/tzgg', infoPaths: ['1080'] },
71
+ jrfzyjy: { name: '金融发展研究院', origin: 'https://jrfzyjy.sdufe.edu.cn', listPath: '/index/tzgg.htm', pagePath: '/index/tzgg', infoPaths: ['1013'] },
72
+ jsgzb: { name: '教师工作部', origin: 'https://jsgzb.sdufe.edu.cn', listPath: '/index/tzgg.htm', pagePath: '/index/tzgg', infoPaths: ['1127'] },
73
+ kyc: { name: '科研处', origin: 'https://kyc.sdufe.edu.cn', listPath: '/tzgg.htm', pagePath: '/tzgg', infoPaths: ['1077'] },
74
+ law: { name: '法学院', origin: 'https://law.sdufe.edu.cn', listPath: '/index/tzgg.htm', pagePath: '/index/tzgg', infoPaths: ['1287'] },
75
+ lib: { name: '图书馆', origin: 'https://lib.sdufe.edu.cn', listPath: '/index/tzgg.htm', pagePath: '/index/tzgg', infoPaths: ['1094'] },
76
+ mba: { name: 'MBA学院', origin: 'https://mba.sdufe.edu.cn', listPath: '/tzgg.htm', pagePath: '/tzgg', infoPaths: ['1372', '1376'] },
77
+ mpa: { name: 'MPA中心', origin: 'https://mpa.sdufe.edu.cn', listPath: '/xwtz/tzgg.htm', pagePath: '/xwtz/tzgg', infoPaths: ['1027'] },
78
+ news: { name: '新闻网', origin: 'https://news.sdufe.edu.cn', listPath: '/cdyw.htm', pagePath: '/cdyw', infoPaths: ['1071'] },
79
+ rcb: { name: '人才办', origin: 'https://rcb.sdufe.edu.cn', listPath: '/index/tzgg.htm', pagePath: '/index/tzgg', infoPaths: ['1023'] },
80
+ rsc: { name: '人事处', origin: 'https://rsc.sdufe.edu.cn', listPath: '/index/tzgg.htm', pagePath: '/index/tzgg', infoPaths: ['1047'] },
81
+ sam: { name: '统数学院', origin: 'https://sam.sdufe.edu.cn', listPath: '/tzgg.htm', pagePath: '/tzgg', infoPaths: ['1012', '1051'] },
82
+ sce: { name: '继续教育学院', origin: 'https://sce.sdufe.edu.cn', listPath: '/index/tzgg.htm', pagePath: '/index/tzgg', infoPaths: ['1124', '1125', '1164', '1282', '2241'] },
83
+ sie: { name: '国际教育学院', origin: 'https://sie.sdufe.edu.cn', listPath: '/index/tzgg2.htm', pagePath: '/index/tzgg2', infoPaths: ['1063'] },
84
+ site: { name: '国贸学院', origin: 'https://site.sdufe.edu.cn', listPath: '/index/tzgg.htm', pagePath: '/index/tzgg', infoPaths: ['1009', '1063', '1064'] },
85
+ spft: { name: '财税学院', origin: 'https://spft.sdufe.edu.cn', listPath: '/tzgg1.htm', pagePath: '/tzgg1', infoPaths: ['1010'] },
86
+ spm: { name: '公共管理学院', origin: 'https://spm.sdufe.edu.cn', listPath: '/index/tzgg.htm', pagePath: '/index/tzgg', infoPaths: ['1324'] },
87
+ tzb: { name: '统战部', origin: 'https://tzb.sdufe.edu.cn', listPath: '/tzgg.htm', pagePath: '/tzgg', infoPaths: ['1031'] },
88
+ xkjsbgs: { name: '学科办', origin: 'https://xkjsbgs.sdufe.edu.cn', listPath: '/index/tzgg.htm', pagePath: '/index/tzgg', infoPaths: ['1106'] },
89
+ xxgk: { name: '信息公开', origin: 'https://xxgk.sdufe.edu.cn', listPath: '/ejlm/xwyxk/xwsybfhsyjd.htm', pagePath: '/ejlm/xwyxk/xwsybfhsyjd', infoPaths: ['1115'] },
90
+ yjsy: { name: '研究生院', origin: 'https://yjsy.sdufe.edu.cn', listPath: '/index/tzgg1.htm', pagePath: '/index/tzgg1', infoPaths: ['1027', '1030', '1031', '1032', '1043', '1058'] },
91
+ ys: { name: '燕山学院', origin: 'https://ys.sdufe.edu.cn', listPath: '/xwtz/zhxw.htm', pagePath: '/xwtz/zhxw', infoPaths: ['1018', '1053'] },
92
+ zfjxpj: { name: '政府绩效评价', origin: 'https://zfjxpj.sdufe.edu.cn', listPath: '/zxdt/tzgg.htm', pagePath: '/zxdt/tzgg', infoPaths: ['1010', '1015', '1018', '1037'] },
93
+ zzb: { name: '组织部', origin: 'https://zzb.sdufe.edu.cn', listPath: '/index/tzgg.htm', pagePath: '/index/tzgg', infoPaths: ['1046', '1076'] },
94
+ }
95
+
41
96
  /** 课程属性中文名 → zzdKcSX 表单码(强智 kbxx_kc_ifr 口径) */
42
97
  export const COURSE_TYPE_MAP = {
43
98
  必修: '1',
@@ -386,24 +441,89 @@ export function createSdufeAdapter(env) {
386
441
 
387
442
  /**
388
443
  * jwc.sdufe.edu.cn 通知公告(公开源,无需登录;T17 双源之一)。
444
+ * T46 起委托三源通用 fetchPortalNotices(行为不变)。
389
445
  * 礼貌纪律:只抓请求的页(分页路径 tzgg/2.htm…),不预取整站。
390
446
  */
391
447
  async fetchJwcNotices(page = 1) {
392
- const path = page <= 1 ? '/zxdt/tzgg.htm' : `/zxdt/tzgg/${page - 1}.htm`
448
+ return this.fetchPortalNotices('jwc', page)
449
+ },
450
+
451
+ /**
452
+ * 校园官网公开通知源列表(T46 三源起步,T46 扩展 36 源;博达 CMS 同构
453
+ * 一套解析,逐站实勘配置见 PORTAL_NOTICE_SOURCES)。
454
+ * 翻页口径:page 1=listPath,page N={pagePath}/{N-1}.htm;
455
+ * pagePath 为空=该源翻页未实证(page>1 明确报错,不静默回首页)。
456
+ * 礼貌纪律:只抓请求的页,不做归档批量预取。
457
+ */
458
+ async fetchPortalNotices(site, page = 1) {
459
+ const src = PORTAL_NOTICE_SOURCES[site]
460
+ if (!src) {
461
+ throw new LinkeError('BAD_SOURCE', `未知通知源:${site}`, {
462
+ exitCode: EXIT.GENERAL,
463
+ hint: 'source 取源注册表 key(jwc=教务处 / main=主站 / xgb=学工部 / lib=图书馆 / yjsy=研究生院 / news=新闻网 / 各学院与职能部门拼音缩写)',
464
+ })
465
+ }
466
+ if (page > 1 && !src.pagePath) {
467
+ throw new LinkeError('NO_PAGER', `${src.name}暂不支持翻页(该站翻页形态特殊未实证)`, {
468
+ exitCode: EXIT.GENERAL,
469
+ hint: '本源仅可查第一页(page=1)',
470
+ })
471
+ }
472
+ const path = page <= 1 ? src.listPath : `${src.pagePath}/${page - 1}.htm`
473
+ let response
474
+ try {
475
+ response = await env.fetch(src.origin + path, {
476
+ headers: { 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) linke-cli' },
477
+ redirect: 'follow',
478
+ timeoutMs: 15000,
479
+ })
480
+ } catch (err) {
481
+ throw networkError(`抓取${src.name}公告页`, err)
482
+ }
483
+ if (!response.ok) {
484
+ throw networkError(`抓取${src.name}公告页(HTTP ${response.status})`, null)
485
+ }
486
+ // 栏目过滤:列表页含导航/侧栏的 info 链接(如主站「学校概况」),
487
+ // 只保留本源通知栏目条目
488
+ return parsePortalNotices(await response.text(), src.origin)
489
+ .filter((x) => !src.infoPaths || !src.infoPaths.length || src.infoPaths.some((c) => x.url.includes(`/info/${c}/`)))
490
+ },
491
+
492
+ /**
493
+ * 校园官网公开通知详情全文(T46)。url 限源注册表内域名(白名单防抓任意站);
494
+ * 礼貌纪律:只抓用户点名的这一篇,不做批量预取。
495
+ */
496
+ async fetchPortalNoticeDetail(url) {
497
+ let parsed
498
+ try {
499
+ parsed = new URL(url)
500
+ } catch {
501
+ throw new LinkeError('BAD_URL', `通知详情 URL 无效:${url}`, {
502
+ exitCode: EXIT.GENERAL,
503
+ hint: 'url 从 notices / get_notices 列表结果的 url 字段取(如 https://jwc.sdufe.edu.cn/info/1043/5995.htm)',
504
+ })
505
+ }
506
+ const src = Object.values(PORTAL_NOTICE_SOURCES).find((s) => s.origin === parsed.origin)
507
+ if (!src || !/^\/info\/\d+\/\d+\.htm$/.test(parsed.pathname)) {
508
+ throw new LinkeError('BAD_URL', `URL 不在校内通知源范围:${url}`, {
509
+ exitCode: EXIT.GENERAL,
510
+ hint: `仅支持通知源注册表内各站(jwc/main/xgb/lib/yjsy/news/各学院等)的 info/{栏目}/{id}.htm 详情页`,
511
+ })
512
+ }
393
513
  let response
394
514
  try {
395
- response = await env.fetch('https://jwc.sdufe.edu.cn' + path, {
515
+ response = await env.fetch(url, {
396
516
  headers: { 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) linke-cli' },
397
517
  redirect: 'follow',
398
518
  timeoutMs: 15000,
399
519
  })
400
520
  } catch (err) {
401
- throw networkError('抓取教务处公告页', err)
521
+ throw networkError(`抓取${src.name}通知详情`, err)
402
522
  }
403
523
  if (!response.ok) {
404
- throw networkError(`抓取教务处公告页(HTTP ${response.status})`, null)
524
+ throw networkError(`抓取${src.name}通知详情(HTTP ${response.status})`, null)
405
525
  }
406
- return parseJwcNotices(await response.text())
526
+ return parsePortalNoticeDetail(await response.text(), url)
407
527
  },
408
528
 
409
529
  /** 教务系统「已收公告」(个人收件,登录态;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,189 @@ 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
+ // 兼容条目形态(逐站实勘判例):
880
+ // - href 属性位不定(news/xxgk:<a target="_blank" href="info/…">)
881
+ // - href 三种相对形态:../info/{栏}/{id}.htm、/info/…、info/…(无前导斜杠)
882
+ // - a 内可嵌日期 span(lib/yjsy/news/cs 等新模板:<a>标题<span class="spans">日期</span></a>)
883
+ const linkRe = /<a\s[^>]*?href="((?:[^"]*?\/)?info\/\d+\/\d+\.htm)"[^>]*>([\s\S]*?)<\/a>/g
884
+ let m
885
+ while ((m = linkRe.exec(html))) {
886
+ let url = m[1]
887
+ if (!/^https?:/i.test(url)) {
888
+ // 相对列表页目录 → 站点根 /info/x(博达栏目均为根级 info/)
889
+ url = baseUrl.replace(/\/$/, '') + '/' + url.replace(/^(\.\.\/)+/, '').replace(/^\//, '')
890
+ }
891
+ if (seen.has(url)) continue
892
+ seen.add(url)
893
+ const inner = decodeEntities(m[2].replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim())
894
+ // yjsy/cs 等新模板把日期放 a 内文本开头 → 剥前缀日期当 date
895
+ let title = inner
896
+ let date = ''
897
+ const inline = inner.match(/^(\d{4}-\d{2}-\d{2})\s*(.*)$/)
898
+ const inlineCn = inner.match(/^(\d{4}年\d{1,2}月\d{1,2}日)\s*(.*)$/)
899
+ if (inline) { date = inline[1]; title = inline[2] || inner }
900
+ else if (inlineCn) { date = inlineCn[1]; title = inlineCn[2] || inner }
901
+ const after = html.slice(m.index + m[0].length, m.index + m[0].length + 600)
902
+ const before = html.slice(Math.max(0, m.index - 600), m.index)
903
+ // 日期其余形态:a 内 span 徽章 → a 后窗口 → a 前 day+month 分元素(学工部)
904
+ if (!date) date = (m[2].match(/\d{4}-\d{2}-\d{2}/) || [])[0] || ''
905
+ if (!date) date = (after.match(/\d{4}-\d{2}-\d{2}/) || [])[0] || ''
906
+ if (!date) {
907
+ const dm = before.match(/class="day">\s*(\d{1,2})\s*<\/p>\s*<p class="month">\s*(\d{4}-\d{2})/)
908
+ if (dm) date = `${dm[2]}-${dm[1].padStart(2, '0')}`
909
+ }
910
+ list.push({ title, url, date })
911
+ }
912
+ return list
913
+ }
914
+
915
+ /**
916
+ * 定位详情正文容器 .v_news_content 并按 div 平衡提取其 innerHTML
917
+ * (主站页含 2 个 v_news_content——首个内嵌 script 取空的取非空者)。
918
+ * 找不到 → null(调用方决定报错口径:明确报错不空转,T46 验收 3)。
919
+ */
920
+ function extractVNewsContent(html) {
921
+ const openTagRe = /<div[^>]*class="[^"]*v_news_content[^"]*"[^>]*>/gi
922
+ let om
923
+ while ((om = openTagRe.exec(html))) {
924
+ const from = om.index + om[0].length
925
+ let depth = 1
926
+ const tagRe = /<\/div\s*>|<div\b[^>]*>/gi
927
+ tagRe.lastIndex = from
928
+ let t
929
+ while ((t = tagRe.exec(html))) {
930
+ if (t[0][1] === '/') depth--
931
+ else depth++
932
+ if (depth === 0) {
933
+ const inner = html.slice(from, t.index)
934
+ // 首个容器可能为空壳(script 注入型),取下一个非空者
935
+ if (htmlToText(inner).length > 0) return inner
936
+ break
937
+ }
938
+ }
939
+ }
940
+ return null
941
+ }
942
+
943
+ /**
944
+ * 博达 CMS 通知详情页通用解析(T46 三源通吃)。
945
+ * → { title, date, content, attachments: [{ name, url }] }
946
+ * - 标题:h1 优先,fallback title(剥站名后缀)
947
+ * - 日期:arttime(YY-MM-DD 补 20 前缀)/ 全页 YYYY-MM-DD / 发布时间元素
948
+ * - 正文:.v_news_content 结构化文本(表格转 | 分列行)
949
+ * - 附件:正文内 a[href 含 .doc/.pdf//system/_ 等] 列表化(不下载)
950
+ * 容器缺失抛 parseError(改版/非通知页明确报错,验收 3 口径)。
951
+ */
952
+ export function parsePortalNoticeDetail(html, pageUrl = '') {
953
+ if (!html || typeof html !== 'string' || !extractVNewsContent(html)) {
954
+ throw parseError('通知详情正文(.v_news_content 容器缺失——页面可能已改版或非通知页)')
955
+ }
956
+ let title = ''
957
+ const h1 = html.match(/<h1[^>]*>([\s\S]*?)<\/h1>/)
958
+ if (h1) {
959
+ title = decodeEntities(h1[1].replace(/<[^>]+>/g, '').trim())
960
+ } else {
961
+ const t = html.match(/<title>([^<]*)<\/title>/)
962
+ title = t ? decodeEntities(t[1].replace(/-[^-]*$/, '').trim()) : ''
963
+ }
964
+ let date = ''
965
+ const arttime = html.match(/class="arttime"[\s\S]{0,200}?(\d{2})-(\d{2})-(\d{2})/)
966
+ if (arttime) date = `20${arttime[1]}-${arttime[2]}-${arttime[3]}`
967
+ if (!date) {
968
+ const pub = html.match(/(?:发布时间|发布日期)[::\s]*(\d{4}-\d{2}-\d{2})/)
969
+ if (pub) date = pub[1]
970
+ }
971
+ if (!date) date = (html.match(/\d{4}-\d{2}-\d{2}/) || [])[0] || ''
972
+
973
+ const inner = extractVNewsContent(html)
974
+ const content = htmlToText(inner)
975
+
976
+ const attachments = []
977
+ const attRe = /<a\s+href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/g
978
+ let a
979
+ const origin = (() => {
980
+ try { return new URL(pageUrl).origin } catch { return '' }
981
+ })()
982
+ while ((a = attRe.exec(inner))) {
983
+ const href = a[1]
984
+ const isAtt = /\.(docx?|xlsx?|pptx?|pdf|zip|rar|txt)(\?|$)/i.test(href) || href.includes('/system/_')
985
+ if (!isAtt) continue
986
+ let url = href
987
+ if (!/^https?:/i.test(url) && origin) {
988
+ url = origin + (url.startsWith('/') ? '' : '/') + url.replace(/^(\.\.\/)+/, '')
989
+ url = url.replace(/([^:])\/+/g, '$1/')
990
+ }
991
+ const name = decodeEntities(a[2].replace(/<[^>]+>/g, '').trim()) || decodeEntities((href.split('wbfileid=')[1] || '').slice(0, 8)) || '附件'
992
+ attachments.push({ name, url })
993
+ }
994
+ return { title, date, content, attachments }
995
+ }