hnptc_01.js 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480
  1. // 湖南邮电职业技术学院 · 湘邮教学管理平台(强智 jsxsd)
  2. // 适配作者:小漫君
  3. // 学生端入口:http://jx.hnxyjf.com:10081/jsxsd/
  4. // 使用流程:在软件内置浏览器登录学生端,点击导入并选择学期。
  5. // 说明:直接请求整学期课表与教学周历;作息参考学校 2025-2026 学年校历,后续调整请核对。
  6. const HNPTC_KB_URL = '/jsxsd/xskb/xskb_list.do';
  7. const HNPTC_CALENDAR_URL = '/jsxsd/jxzl/jxzl_query';
  8. const HNPTC_POST_OPTIONS = { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } };
  9. // https://jwc.hnptc.edu.cn/2025_09/19_09/content-30331.html
  10. const HNPTC_TIME_SLOTS = [
  11. { number: 1, startTime: '08:30', endTime: '09:15' },
  12. { number: 2, startTime: '09:25', endTime: '10:10' },
  13. { number: 3, startTime: '10:25', endTime: '11:10' },
  14. { number: 4, startTime: '11:20', endTime: '12:05' },
  15. { number: 5, startTime: '14:00', endTime: '14:45' },
  16. { number: 6, startTime: '14:55', endTime: '15:40' },
  17. { number: 7, startTime: '15:50', endTime: '16:35' },
  18. { number: 8, startTime: '16:45', endTime: '17:30' },
  19. { number: 9, startTime: '19:30', endTime: '20:15' },
  20. { number: 10, startTime: '20:25', endTime: '21:10' }
  21. ];
  22. // ==================== 桥接封装 ====================
  23. function toast(message) {
  24. if (window.shiguangBridge && typeof window.shiguangBridge.showToast === 'function') {
  25. window.shiguangBridge.showToast(message);
  26. } else {
  27. console.log('[HNPTC] ' + message);
  28. }
  29. }
  30. async function alertUser(title, message) {
  31. if (window.shiguangBridgePromise && typeof window.shiguangBridgePromise.showAlert === 'function') {
  32. const confirmed = await window.shiguangBridgePromise.showAlert(title, message, '知道了');
  33. return confirmed === true || confirmed === 'true';
  34. }
  35. alert(title + '\n' + message);
  36. return true;
  37. }
  38. // 原生以字符串序号或 "null" 回传,统一为数字序号,-1 表示取消
  39. async function selectFromList(title, items, defaultIndex) {
  40. if (!(window.shiguangBridgePromise && typeof window.shiguangBridgePromise.showSingleSelection === 'function')) {
  41. return defaultIndex;
  42. }
  43. const result = await window.shiguangBridgePromise.showSingleSelection(title, JSON.stringify(items), defaultIndex);
  44. if (result === null || result === undefined || result === 'null') return -1;
  45. const index = typeof result === 'number' ? result : parseInt(result, 10);
  46. return isNaN(index) ? -1 : index;
  47. }
  48. // ==================== 请求 ====================
  49. // 兼容直连与 WebVPN 形态的地址,取 /jsxsd/ 之前的部分作为前缀
  50. function jsxsdUrl(path) {
  51. const currentPath = window.location.pathname || '';
  52. const index = currentPath.indexOf('/jsxsd/');
  53. return window.location.origin + (index < 0 ? '' : currentPath.slice(0, index)) + path;
  54. }
  55. async function requestText(url, options) {
  56. const response = await fetch(url, Object.assign({ credentials: 'include' }, options || {}));
  57. if (!response.ok) throw new Error('教务请求失败(HTTP ' + response.status + ')');
  58. return await response.text();
  59. }
  60. // 课表页表单字段与页面 Form1 一致,POST 失败时退回带查询参数的 GET
  61. async function fetchTimetableDoc(semesterValue, kbjcmsid) {
  62. const url = jsxsdUrl(HNPTC_KB_URL);
  63. const body = 'jx0404id=&cj0701id=&zc=&demo=&xnxq01id=' + encodeURIComponent(semesterValue) +
  64. '&sfFD=1&kbjcmsid=' + encodeURIComponent(kbjcmsid);
  65. const posted = new DOMParser().parseFromString(
  66. await requestText(url, Object.assign({ method: 'POST', body: body }, HNPTC_POST_OPTIONS)), 'text/html');
  67. if (findTimetableTable(posted)) return posted;
  68. const got = new DOMParser().parseFromString(
  69. await requestText(url + '?xnxq01id=' + encodeURIComponent(semesterValue)), 'text/html');
  70. return findTimetableTable(got) ? got : null;
  71. }
  72. // ==================== 学期与教学周历 ====================
  73. // 学期列表:[{ value: "2026-2027-1", label: "2026-2027 学年第 1 学期", selected: true }]
  74. function parseSemesters(doc) {
  75. const select = doc.querySelector('select[name="xnxq01id"], select#xnxq01id');
  76. if (!select) return [];
  77. const list = [];
  78. select.querySelectorAll('option').forEach(function (option) {
  79. const value = String(option.getAttribute('value') || '').trim();
  80. if (!value) return;
  81. list.push({
  82. value: value,
  83. label: value.replace(/^(\d{4})-(\d{4})-(\d)$/, '$1-$2 学年第 $3 学期'),
  84. selected: option.hasAttribute('selected')
  85. });
  86. });
  87. return list;
  88. }
  89. function parseKbjcmsid(doc) {
  90. const select = doc.querySelector('select[name="kbjcmsid"]');
  91. const option = select ? (select.querySelector('option[selected]') || select.querySelector('option')) : null;
  92. return option ? String(option.getAttribute('value') || '').trim() : '';
  93. }
  94. // 教学周历:首列为周次序号,第二列含该周星期一日期
  95. function parseCalendar(doc) {
  96. const result = { semesterStartDate: null, totalWeeks: null };
  97. const table = doc.querySelector('table');
  98. if (!table) return result;
  99. const toDate = function (text) {
  100. const match = String(text || '').match(/(\d{4})\s*[-年]\s*(\d{1,2})\s*[-月]\s*(\d{1,2})/);
  101. return match ? match[1] + '-' + match[2].padStart(2, '0') + '-' + match[3].padStart(2, '0') : null;
  102. };
  103. Array.from(table.querySelectorAll('tr')).forEach(function (row) {
  104. const cells = Array.from(row.children);
  105. const first = cells.length > 0 ? String(cells[0].textContent).trim() : '';
  106. if (!/^\d+$/.test(first)) return;
  107. const week = parseInt(first, 10);
  108. result.totalWeeks = result.totalWeeks === null ? week : Math.max(result.totalWeeks, week);
  109. if (week === 1 && !result.semesterStartDate && cells.length > 1) {
  110. result.semesterStartDate = toDate(cells[1].getAttribute('title')) || toDate(cells[1].textContent);
  111. }
  112. });
  113. return result;
  114. }
  115. async function fetchCalendar(semesterValue) {
  116. try {
  117. const text = await requestText(jsxsdUrl(HNPTC_CALENDAR_URL), Object.assign(
  118. { method: 'POST', body: 'xnxq01id=' + encodeURIComponent(semesterValue) }, HNPTC_POST_OPTIONS));
  119. return parseCalendar(new DOMParser().parseFromString(text, 'text/html'));
  120. } catch (error) {
  121. console.warn('[HNPTC] 教学周历读取失败:', error);
  122. return { semesterStartDate: null, totalWeeks: null };
  123. }
  124. }
  125. // ==================== 课表解析 ====================
  126. // 不同强智版本分别使用 #timetable 与 #kbtable
  127. function findTimetableTable(doc) {
  128. return doc.getElementById('timetable') || doc.getElementById('kbtable');
  129. }
  130. // 周次与节次:兼容 1-16(周)[01-02节]、1-8,10-16(周)、1-15周(单)、2-16(双)、第3-4节 等写法
  131. function parseWeeksAndSections(text) {
  132. const result = { weeks: [], sections: [] };
  133. const content = String(text || '').trim();
  134. if (!content) return result;
  135. const parity = /双/.test(content) ? 2 : (/单/.test(content) ? 1 : 0);
  136. const bracketIndex = content.indexOf('[');
  137. const cleaned = (bracketIndex >= 0 ? content.slice(0, bracketIndex) : content)
  138. .replace(/第/g, '')
  139. .replace(/至|到|~/g, '-')
  140. .replace(/[((][^()()]*[))]/g, '')
  141. .replace(/周/g, '');
  142. cleaned.split(/[,,、;;\s]+/).forEach(function (segment) {
  143. const item = segment.trim();
  144. const range = item.match(/^(\d+)\s*[-–—]\s*(\d+)$/);
  145. if (range) {
  146. for (let week = parseInt(range[1], 10); week <= parseInt(range[2], 10); week++) result.weeks.push(week);
  147. } else if (/^\d+$/.test(item)) {
  148. result.weeks.push(parseInt(item, 10));
  149. }
  150. });
  151. if (parity) {
  152. result.weeks = result.weeks.filter(function (week) { return week % 2 === (parity === 1 ? 1 : 0); });
  153. }
  154. result.weeks = Array.from(new Set(result.weeks)).sort(function (a, b) { return a - b; });
  155. const sectionMatch = content.match(/\[([^\]]*)\]/);
  156. if (sectionMatch) {
  157. (sectionMatch[1].match(/\d+/g) || []).forEach(function (number) {
  158. const section = parseInt(number, 10);
  159. if (result.sections.indexOf(section) < 0) result.sections.push(section);
  160. });
  161. result.sections.sort(function (a, b) { return a - b; });
  162. }
  163. return result;
  164. }
  165. // 收集单个课表单元格内的全部课程候选。强智在格内同时输出简表与详表,
  166. // 简表缺少教师与节次方括号,此处一并解析,交由归并阶段择优。
  167. function collectCellCandidates(cellDiv, day, output, rowSections) {
  168. const html = String(cellDiv.innerHTML || '').trim();
  169. if (!html || html.replace(/&nbsp;/gi, '').trim() === '') return;
  170. html.split(/[-—–]{6,}\s*(?:<br\s*\/?>)?/i).forEach(function (blockHtml) {
  171. if (!blockHtml.trim()) return;
  172. const holder = document.createElement('div');
  173. holder.innerHTML = blockHtml;
  174. let name = '';
  175. for (let i = 0; i < holder.childNodes.length; i++) {
  176. const node = holder.childNodes[i];
  177. if (node.nodeType === 3 && /[\u4e00-\u9fa5]/.test(node.textContent)) {
  178. name = node.textContent.trim();
  179. break;
  180. }
  181. }
  182. if (!name) name = String(holder.textContent || '').split('\n')[0].trim();
  183. if (!name) return;
  184. const pickText = function (titles) {
  185. for (let i = 0; i < titles.length; i++) {
  186. const element = holder.querySelector('font[title*="' + titles[i] + '"]');
  187. if (element && element.textContent.trim()) return element.textContent.trim();
  188. }
  189. return '';
  190. };
  191. let timeText = pickText(['周次', '节次']);
  192. if (!/\[[^\]]*\]/.test(timeText)) {
  193. const bracket = String(holder.textContent || '').match(/\[[^\]]*\]/);
  194. if (bracket) timeText += bracket[0];
  195. }
  196. const parsed = parseWeeksAndSections(timeText || holder.textContent);
  197. if (parsed.weeks.length === 0) return;
  198. if (parsed.sections.length === 0 && rowSections) {
  199. for (let section = rowSections[0]; section <= rowSections[1]; section++) parsed.sections.push(section);
  200. }
  201. const teacher = pickText(['教师', '老师']);
  202. const position = pickText(['教室', '地点']).replace(/\[\s*\d+\s*-\s*\d+\s*\]\s*节?$/, '').trim();
  203. output.push({
  204. name: name,
  205. teacher: teacher,
  206. position: position,
  207. day: day,
  208. weeks: parsed.weeks,
  209. sections: parsed.sections,
  210. score: (teacher ? 4 : 0) + (/\[[^\]]*\]/.test(timeText) ? 3 : 0) +
  211. (position ? 1 : 0) + (parsed.sections.length > 1 ? 1 : 0)
  212. });
  213. });
  214. }
  215. // 按课程名、周次、教室归并候选,保留信息更完整的一条
  216. function mergeCellCandidates(candidates) {
  217. const groups = {};
  218. const order = [];
  219. candidates.forEach(function (item) {
  220. const key = item.name + '|' + item.weeks.join(',') + '|' + item.position;
  221. if (groups[key] === undefined) {
  222. groups[key] = item;
  223. order.push(key);
  224. } else if (item.score > groups[key].score) {
  225. groups[key] = item;
  226. }
  227. });
  228. const courses = [];
  229. order.forEach(function (key) {
  230. const item = groups[key];
  231. if (item.sections.length === 0) return;
  232. courses.push({
  233. name: item.name,
  234. teacher: item.teacher || '未知教师',
  235. position: item.position || '未知地点',
  236. day: item.day,
  237. weeks: item.weeks,
  238. startSection: item.sections[0],
  239. endSection: item.sections[item.sections.length - 1]
  240. });
  241. });
  242. return courses;
  243. }
  244. function parseTimetable(doc) {
  245. const table = findTimetableTable(doc);
  246. if (!table) return [];
  247. const courses = [];
  248. Array.from(table.querySelectorAll('tr')).forEach(function (row) {
  249. const label = row.querySelector('th');
  250. const labelMatch = label ? String(label.textContent).match(/(\d+)\s*[~~\-—]\s*(\d+)\s*节/) : null;
  251. const rowSections = labelMatch ? [parseInt(labelMatch[1], 10), parseInt(labelMatch[2], 10)] : null;
  252. Array.from(row.querySelectorAll('td')).forEach(function (cell, index) {
  253. const blocks = cell.querySelectorAll('div.kbcontent, div.kbcontent1');
  254. if (blocks.length === 0) return;
  255. const candidates = [];
  256. Array.from(blocks).forEach(function (block) {
  257. collectCellCandidates(block, index + 1, candidates, rowSections);
  258. });
  259. mergeCellCandidates(candidates).forEach(function (course) {
  260. courses.push(course);
  261. });
  262. });
  263. });
  264. return courses;
  265. }
  266. // 节次与周次合并去重函数
  267. // 来源:官方 Wiki《课程合并与去重函数》
  268. // https://github.com/XingHeYuZhuan/shiguangschedule/wiki/课程合并与去重函数
  269. // 阶段一按名称、教师、地点、星期、周次一致合并连续节次并去除完全重复,阶段二按相同节次合并周次
  270. function mergeAndDistinctCourses(courses) {
  271. if (!Array.isArray(courses) || courses.length <= 1) return courses;
  272. const list = courses.map(c => ({
  273. ...c,
  274. name: c.name || '',
  275. teacher: c.teacher || '',
  276. position: c.position || '',
  277. weeks: Array.isArray(c.weeks) ? [...c.weeks].sort((a, b) => a - b) : []
  278. }));
  279. list.sort((a, b) => {
  280. return a.name.localeCompare(b.name) ||
  281. a.teacher.localeCompare(b.teacher) ||
  282. a.position.localeCompare(b.position) ||
  283. (a.day || 0) - (b.day || 0) ||
  284. a.weeks.join(',').localeCompare(b.weeks.join(',')) ||
  285. (a.startSection || 0) - (b.startSection || 0);
  286. });
  287. const step1Merged = [];
  288. let current = list[0];
  289. for (let i = 1; i < list.length; i++) {
  290. const next = list[i];
  291. const isSameCourseAndWeeks =
  292. current.name === next.name &&
  293. current.teacher === next.teacher &&
  294. current.position === next.position &&
  295. current.day === next.day &&
  296. current.weeks.join(',') === next.weeks.join(',');
  297. const isContinuous = current.endSection + 1 === next.startSection;
  298. const isDuplicate = current.startSection === next.startSection && current.endSection === next.endSection;
  299. if (isSameCourseAndWeeks && isContinuous) {
  300. current.endSection = next.endSection;
  301. } else if (isSameCourseAndWeeks && isDuplicate) {
  302. continue;
  303. } else {
  304. step1Merged.push(current);
  305. current = next;
  306. }
  307. }
  308. step1Merged.push(current);
  309. step1Merged.sort((a, b) => {
  310. return a.name.localeCompare(b.name) ||
  311. a.teacher.localeCompare(b.teacher) ||
  312. a.position.localeCompare(b.position) ||
  313. (a.day || 0) - (b.day || 0) ||
  314. (a.startSection || 0) - (b.startSection || 0) ||
  315. (a.endSection || 0) - (b.endSection || 0);
  316. });
  317. const step2Merged = [];
  318. let merged = step1Merged[0];
  319. for (let i = 1; i < step1Merged.length; i++) {
  320. const next = step1Merged[i];
  321. const isSameCourseAndSection =
  322. merged.name === next.name &&
  323. merged.teacher === next.teacher &&
  324. merged.position === next.position &&
  325. merged.day === next.day &&
  326. merged.startSection === next.startSection &&
  327. merged.endSection === next.endSection;
  328. if (isSameCourseAndSection) {
  329. merged.weeks = Array.from(new Set([...merged.weeks, ...next.weeks])).sort((a, b) => a - b);
  330. } else {
  331. step2Merged.push(merged);
  332. merged = next;
  333. }
  334. }
  335. step2Merged.push(merged);
  336. return step2Merged;
  337. }
  338. // ==================== 流程编排 ====================
  339. async function runImportFlow() {
  340. try {
  341. if ((window.location.pathname || '').indexOf('/jsxsd/') < 0) {
  342. await alertUser('请先进入教务系统', '请登录教务系统并停留在任意教务功能页面后重新执行导入。');
  343. return;
  344. }
  345. toast('正在获取学期信息…');
  346. const listDoc = new DOMParser().parseFromString(await requestText(jsxsdUrl(HNPTC_KB_URL)), 'text/html');
  347. const semesters = parseSemesters(listDoc);
  348. if (semesters.length === 0) {
  349. await alertUser('未获取到学期列表', '课表页返回异常,请确认登录状态有效后重试。');
  350. return;
  351. }
  352. let defaultIndex = 0;
  353. semesters.forEach(function (item, index) { if (item.selected) defaultIndex = index; });
  354. // 即使只有一个学期,也交给宿主记忆/核对选项,避免后台刷新时默默切换学期。
  355. const picked = await selectFromList('选择学期', semesters.map(function (item) { return item.label; }), defaultIndex);
  356. if (picked < 0 || picked >= semesters.length) {
  357. toast('导入已取消');
  358. return;
  359. }
  360. const semester = semesters[picked];
  361. toast('正在获取 ' + semester.label + ' 课表…');
  362. const tableDoc = await fetchTimetableDoc(semester.value, parseKbjcmsid(listDoc));
  363. if (!tableDoc) {
  364. await alertUser('未获取到课表数据', '请确认已登录教务系统后重试。');
  365. return;
  366. }
  367. const courses = mergeAndDistinctCourses(parseTimetable(tableDoc));
  368. if (courses.length === 0) {
  369. await alertUser('未找到课程', semester.label + ' 页面中没有课程内容,该学期可能尚未发布课表。');
  370. return;
  371. }
  372. const calendar = await fetchCalendar(semester.value);
  373. const weeks = courses.reduce(function (all, course) { return all.concat(course.weeks); }, []);
  374. const config = {
  375. semesterTotalWeeks: calendar.totalWeeks || Math.max.apply(null, weeks)
  376. };
  377. if (calendar.semesterStartDate) {
  378. config.semesterStartDate = calendar.semesterStartDate;
  379. } else {
  380. toast('未读取到教学周历,开学日期可在软件内手动校准');
  381. }
  382. await window.shiguangBridgePromise.saveCourseConfig(JSON.stringify(config));
  383. const saved = await window.shiguangBridgePromise.saveImportedCourses(JSON.stringify(courses));
  384. if (saved !== true && saved !== 'true') {
  385. toast('课程保存失败,请重试');
  386. return;
  387. }
  388. // 校历只列出十节;若教务课表出现更多节次,保留课程导入并交由用户核对作息。
  389. if (courses.every(function (course) { return course.endSection <= HNPTC_TIME_SLOTS.length; })) {
  390. try {
  391. const timeSaved = await window.shiguangBridgePromise.savePresetTimeSlots(JSON.stringify(HNPTC_TIME_SLOTS));
  392. if (timeSaved === true || timeSaved === 'true') {
  393. toast('已按 2025-2026 学年校历预设作息,请核对学校最新安排');
  394. } else {
  395. toast('作息时间未写入,请手动核对');
  396. }
  397. } catch (error) {
  398. console.warn('[HNPTC] 作息时间未写入:', error);
  399. toast('作息时间未写入,请手动核对');
  400. }
  401. } else {
  402. toast('课表含校历之外的节次,请手动核对作息');
  403. }
  404. toast('导入成功:' + semester.label + ' 共 ' + courses.length + ' 条课程时段');
  405. if (window.shiguangBridge && typeof window.shiguangBridge.notifyTaskCompletion === 'function') {
  406. window.shiguangBridge.notifyTaskCompletion();
  407. }
  408. } catch (error) {
  409. console.error('[HNPTC] 导入流程异常:', error);
  410. await alertUser('导入失败', error && error.message ? error.message : String(error));
  411. }
  412. }
  413. // 启动导入流程
  414. runImportFlow();