hist_01.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380
  1. // 河南科技学院(青果 / KINGOSOFT 教务系统) 拾光课程表适配脚本
  2. //
  3. // 教务系统入口:http://jwgl.hist.edu.cn/cas/login.action
  4. // 课程表数据接口:/wsxk/xkjg.ckdgxsxdkchj_data10319.jsp?params=<base64(xn=..&xq=..)>
  5. // 说明:params 为 Base64,内容是 "xn=2025&xq=0";xh(学号/uid) 可省略,服务端用会话即可。
  6. // xn = 学年(如 2025 表示 2025-2026 学年);xq = 0 第一学期(秋季) / 1 第二学期(春季)。
  7. //
  8. // 适配者:@星河欲转 社区贡献(河南科技学院)
  9. // 说明:本脚本按青果课表接口进行解析,若学校变更菜单编号(10319)或表格结构,
  10. // 请参照文档在“更多→开发者功能”中打开源码调试。
  11. const HOST = 'http://jwgl.hist.edu.cn';
  12. const KB_DATA_URL = HOST + '/wsxk/xkjg.ckdgxsxdkchj_data10319.jsp';
  13. const DAY_MAP = { '一': 1, '二': 2, '三': 3, '四': 4, '五': 5, '六': 6, '日': 7, '天': 7 };
  14. function base64Encode(str) {
  15. return btoa(str);
  16. }
  17. function isLoginPage() {
  18. const url = window.location.href;
  19. if (url.indexOf('/cas/') !== -1) return true;
  20. if (url.indexOf('jwgl.hist.edu.cn') !== -1 && /login\.action|login\.jsp|\/cas\//i.test(url)) return true;
  21. return false;
  22. }
  23. // ---------- token 解析 ----------
  24. function matchDay(text) {
  25. // 兼容 "星期四[1-2]" / "周四[1-2]" / "二[7-8]"(青果导出常为单个汉字)
  26. let m = text.match(/(?:星期|周)([一二三四五六日天])/);
  27. if (m) return { day: DAY_MAP[m[1]] || 7, str: m[1] };
  28. m = text.match(/([一二三四五六日天])\s*\[/);
  29. if (m) return { day: DAY_MAP[m[1]] || 7, str: m[1] };
  30. return null;
  31. }
  32. function matchSection(text) {
  33. // 优先匹配方括号内的节次(青果:星期一[7-8] / 二[7-8]);方括号里的数字必为节次。
  34. let m = text.match(/\[(\d{1,2})\s*[-—~]\s*(\d{1,2})\]/);
  35. if (m) {
  36. const s = parseInt(m[1], 10), e = parseInt(m[2], 10);
  37. if (s >= 1 && e >= s && e <= 30) return { start: s, end: e, str: m[0] };
  38. }
  39. m = text.match(/(\d{1,2})\s*[-—~]\s*(\d{1,2})\s*节/);
  40. if (m) {
  41. const s = parseInt(m[1], 10), e = parseInt(m[2], 10);
  42. if (s >= 1 && e >= s && e <= 30) return { start: s, end: e, str: m[0] };
  43. }
  44. m = text.match(/(?:第)?\s*(\d{1,2})\s*节/);
  45. if (m) {
  46. const s = parseInt(m[1], 10);
  47. if (s >= 1 && s <= 30) return { start: s, end: s, str: m[0] };
  48. }
  49. return null;
  50. }
  51. function matchWeeks(text) {
  52. let flag = 0;
  53. if (text.includes('单')) flag = 1;
  54. else if (text.includes('双')) flag = 2;
  55. let clean = text.replace(/单/g, '').replace(/双/g, '').replace(/[()()]/g, '');
  56. const m = clean.match(/([\d,,\-—~]+)\s*周/);
  57. if (!m) return [];
  58. const weeks = [];
  59. for (const part of m[1].split(/[,,]/)) {
  60. const p = part.trim();
  61. if (!p) continue;
  62. const range = p.match(/^(\d{1,3})[\-—~](\d{1,3})$/);
  63. if (range) {
  64. const s = parseInt(range[1], 10), e = parseInt(range[2], 10);
  65. for (let i = s; i <= e; i++) {
  66. if (flag === 1 && i % 2 !== 0) weeks.push(i);
  67. else if (flag === 2 && i % 2 === 0) weeks.push(i);
  68. else if (flag === 0) weeks.push(i);
  69. }
  70. } else if (/^\d{1,3}$/.test(p)) {
  71. const i = parseInt(p, 10);
  72. if (flag === 1 && i % 2 !== 0) weeks.push(i);
  73. else if (flag === 2 && i % 2 === 0) weeks.push(i);
  74. else if (flag === 0) weeks.push(i);
  75. }
  76. }
  77. return [...new Set(weeks)].sort((a, b) => a - b);
  78. }
  79. function parseTimePlaceCell(text) {
  80. const items = [];
  81. if (!text) return items;
  82. const normalized = text
  83. .replace(///g, '/').replace(/,/g, ',').replace(/;/g, ';')
  84. .replace(/\u3000/g, ' ').replace(/<[^>]+>/g, '');
  85. // 每个时段形如:周次 星期[节次] 地点;多个时段用逗号分隔。
  86. // 用“周次 + 星期[节次]”为锚点整体捕获,避免把周次列表里的逗号(如 6-8,10周)误当成时段分隔。
  87. const re = /(\S*周[^\s\[\]]*)\s*([一二三四五六日天])\s*\[(\d{1,2})\s*[-—~]\s*(\d{1,2})\]\s*([^,,;;\n]*)/g;
  88. let m;
  89. while ((m = re.exec(normalized)) !== null) {
  90. const day = DAY_MAP[m[2]] || 7;
  91. const start = parseInt(m[3], 10), end = parseInt(m[4], 10);
  92. if (!(start >= 1 && end >= start && end <= 30)) continue;
  93. const weeks = matchWeeks(m[1]);
  94. if (weeks.length === 0) continue;
  95. let position = (m[5] || '')
  96. .replace(/\((\d+)\)\s*$/, '') // 去掉结尾教室容量 "(98)"
  97. .replace(/[\[\]()()]/g, ' ')
  98. .replace(/[,,;;\/]/g, ' ')
  99. .replace(/单|双/g, ' ')
  100. .replace(/\s+/g, ' ').trim();
  101. if (position === '' || /^\d+$/.test(position)) position = '';
  102. items.push({ day, startSection: start, endSection: end, weeks, position });
  103. }
  104. // 兜底:若某时段缺地点,而同行后面的时段有地点,则继承该地点
  105. const lastPos = items.map(i => i.position).filter(p => p).slice(-1)[0] || '';
  106. if (lastPos) {
  107. for (const it of items) if (!it.position) it.position = lastPos;
  108. }
  109. return items;
  110. }
  111. // ---------- HTML 解析 ----------
  112. // 清理教师串:格式 "[工号]姓名 [工号]姓名 …" → "姓名 & 姓名"
  113. function cleanTeacher(t) {
  114. const name = t.split('[').map(part => {
  115. const idx = part.indexOf(']');
  116. return idx >= 0 ? part.slice(idx + 1).replace(/&ensp;|&nbsp;/g, ' ').trim() : '';
  117. }).filter(Boolean).join(' & ');
  118. return name || t.trim();
  119. }
  120. function findNameAndTeacher(cells, dataIdx) {
  121. let name = '', teacher = '', nameIdx = -1;
  122. // 课程名:形如 [xxx]课程名
  123. for (let i = 0; i < cells.length; i++) {
  124. if (i === dataIdx) continue;
  125. const t = (cells[i]?.textContent || '').trim();
  126. if (/^\s*\[[^\]]*\]\s*\S/.test(t)) {
  127. name = t.replace(/^\s*\[[^\]]*\]\s*/g, '').trim();
  128. nameIdx = i;
  129. break;
  130. }
  131. }
  132. // 教师:形如 [工号]姓名 …(含 [工号] 且不是课程名)
  133. for (let i = 0; i < cells.length; i++) {
  134. if (i === dataIdx || i === nameIdx) continue;
  135. const t = (cells[i]?.textContent || '').trim();
  136. if (/\d/.test(t) && /\[[^\]]*\]\s*/.test(t)) {
  137. const ct = cleanTeacher(t);
  138. if (ct) { teacher = ct; break; }
  139. }
  140. }
  141. return { name, teacher };
  142. }
  143. function parseScheduleHtml(html) {
  144. const doc = new DOMParser().parseFromString(html, 'text/html');
  145. const rows = doc.querySelectorAll('tr');
  146. const courses = [];
  147. for (const tr of rows) {
  148. const cells = Array.from(tr.querySelectorAll('td'));
  149. if (cells.length < 3) continue;
  150. let dataCell = null;
  151. let dataIdx = -1;
  152. for (let i = cells.length - 1; i >= 0; i--) {
  153. const t = cells[i].textContent || '';
  154. // 时间地点单元格特征:含“周”(周次)或方括号节次 [N-M],或“星期X[”/“X[”
  155. if (/周/.test(t) || /\[(\d{1,2})\s*[-—~]\s*(\d{1,2})\]/.test(t) || /[一二三四五六日天]\s*\[/.test(t)) {
  156. dataCell = t;
  157. dataIdx = i;
  158. break;
  159. }
  160. }
  161. if (dataCell === null) continue;
  162. // 课程名与教师采用“按内容识别”,对导出/接口两种列布局都健壮:
  163. // 课程名单元格形如 "[2500T0002]大学生心理健康教育"
  164. // 教师单元格形如 "[201201059]贾普君 [202201013]李金璐"
  165. const info = findNameAndTeacher(cells, dataIdx);
  166. let name = info.name;
  167. if (!name) name = (cells[2]?.textContent || '').trim() || (cells[1]?.textContent || '').trim() || (cells[0]?.textContent || '').trim();
  168. name = name.replace(/^\s*\[[^\]]*\]\s*/g, '').trim();
  169. if (!name) continue;
  170. const teacher = info.teacher;
  171. const items = parseTimePlaceCell(String(dataCell));
  172. for (const it of items) {
  173. courses.push({
  174. name: name, teacher: teacher, position: it.position,
  175. day: it.day, startSection: it.startSection, endSection: it.endSection, weeks: it.weeks
  176. });
  177. }
  178. }
  179. return mergeCourses(courses);
  180. }
  181. function mergeCourses(courses) {
  182. const map = new Map();
  183. for (const c of courses) {
  184. const key = [c.name, c.teacher, c.position, c.day, c.startSection, c.endSection].join('|');
  185. if (!map.has(key)) map.set(key, { ...c, weeks: [] });
  186. const entry = map.get(key);
  187. entry.weeks = [...new Set([...entry.weeks, ...c.weeks])].sort((a, b) => a - b);
  188. }
  189. return Array.from(map.values());
  190. }
  191. // ---------- 流程 ----------
  192. function validateYearInput(input) {
  193. if (/^[0-9]{4}$/.test(input)) return false;
  194. return '请输入四位数字的学年!';
  195. }
  196. async function promptUserToStart() {
  197. return await window.shiguangBridgePromise.showAlert(
  198. '教务系统课表导入',
  199. '导入前请确认已在本页面登录教务系统(登录成功后无需再点登录)。',
  200. '好的,开始导入'
  201. );
  202. }
  203. async function selectYear() {
  204. const now = new Date();
  205. const month = now.getMonth() + 1;
  206. let year = now.getFullYear();
  207. if (month < 7) year -= 1;
  208. const input = await window.shiguangBridgePromise.showPrompt(
  209. '选择学年',
  210. '请输入要导入课程的起始学年(例如 2025-2026 学年输入 2025):',
  211. String(year), 'validateYearInput'
  212. );
  213. if (input === null) return null;
  214. return parseInt(input, 10);
  215. }
  216. async function selectSemester() {
  217. const semesters = ['第一学期(秋季)', '第二学期(春季)'];
  218. return await window.shiguangBridgePromise.showSingleSelection('选择学期', JSON.stringify(semesters), 0);
  219. }
  220. // 自动判断响应编码:UTF-8 与 GBK 二选一(谁替换字符少、中文多就用谁)
  221. function decodeBest(buf) {
  222. const utf8 = (() => { try { return new TextDecoder('utf-8').decode(buf); } catch (e) { return ''; } })();
  223. const gbk = (() => { try { return new TextDecoder('gbk').decode(buf); } catch (e) { return null; } })();
  224. const repl = s => (s.match(/\uFFFD/g) || []).length;
  225. const cjk = s => (s.match(/[\u4e00-\u9fa5]/g) || []).length;
  226. if (gbk == null) return utf8;
  227. if (repl(utf8) === 0 && repl(gbk) > 0) return utf8;
  228. if (repl(gbk) === 0 && repl(utf8) > 0) return gbk;
  229. if (cjk(gbk) > cjk(utf8)) return gbk;
  230. return repl(gbk) < repl(utf8) ? gbk : utf8;
  231. }
  232. async function fetchScheduleHtml(academicYear, semesterIndex) {
  233. const xq = semesterIndex === 0 ? 0 : 1;
  234. const params = base64Encode(`xn=${academicYear}&xq=${xq}`);
  235. const url = `${KB_DATA_URL}?params=${encodeURIComponent(params)}`;
  236. window.shiguangBridge.showToast('正在请求课表数据…');
  237. console.log('HIST: fetch ->', url);
  238. const resp = await fetch(url, { method: 'GET', credentials: 'include' });
  239. const buf = await resp.arrayBuffer();
  240. const text = decodeBest(buf);
  241. if (text.includes('凭证已失效') || text.includes('请重新登录')) {
  242. window.shiguangBridge.showToast('请先登录教务系统!');
  243. return null;
  244. }
  245. return text;
  246. }
  247. // 内置备用作息:key 0=第一学期(2026-2027实测), 1=第二学期(旧版兜底,仅抓取失败时用)
  248. const FALLBACK_TIMES = {
  249. 0: [
  250. { number: 1, startTime: '08:00', endTime: '08:45' }, { number: 2, startTime: '08:55', endTime: '09:40' },
  251. { number: 3, startTime: '10:10', endTime: '10:55' }, { number: 4, startTime: '11:05', endTime: '11:50' },
  252. { number: 5, startTime: '15:00', endTime: '15:45' }, { number: 6, startTime: '15:55', endTime: '16:40' },
  253. { number: 7, startTime: '17:10', endTime: '17:55' }, { number: 8, startTime: '18:05', endTime: '18:50' },
  254. { number: 9, startTime: '20:00', endTime: '20:45' }, { number: 10, startTime: '20:55', endTime: '21:40' }
  255. ],
  256. 1: [
  257. { number: 1, startTime: '08:00', endTime: '08:45' }, { number: 2, startTime: '08:55', endTime: '09:40' },
  258. { number: 3, startTime: '10:10', endTime: '10:55' }, { number: 4, startTime: '11:05', endTime: '11:50' },
  259. { number: 5, startTime: '14:30', endTime: '15:15' }, { number: 6, startTime: '15:25', endTime: '16:10' },
  260. { number: 7, startTime: '16:40', endTime: '17:25' }, { number: 8, startTime: '17:35', endTime: '18:20' },
  261. { number: 9, startTime: '19:30', endTime: '20:15' }, { number: 10, startTime: '20:25', endTime: '21:10' }
  262. ]
  263. };
  264. // 解析教务作息页表格:节次行形如 ["1","08:00","08:45"] 或 ["上午","1","08:00","08:45"]
  265. function parseTimetableHtml(html) {
  266. const doc = new DOMParser().parseFromString(html, 'text/html');
  267. const out = [];
  268. for (const tr of doc.querySelectorAll('tr')) {
  269. const cells = Array.from(tr.querySelectorAll('td,th'))
  270. .map(c => (c.textContent || '').trim()).filter(c => c !== '');
  271. let num = parseInt(cells[0] || '', 10);
  272. if (isNaN(num)) num = parseInt(cells[1] || '', 10);
  273. let start = '', end = '';
  274. for (let i = 1; i < cells.length; i++) {
  275. if (/^\d{2}:\d{2}$/.test(cells[i])) {
  276. if (!start) start = cells[i];
  277. else if (!end) { end = cells[i]; break; }
  278. }
  279. }
  280. if (num >= 1 && num <= 30 && start && end) out.push({ number: num, startTime: start, endTime: end });
  281. }
  282. const map = new Map();
  283. out.forEach(s => { if (!map.has(s.number)) map.set(s.number, s); });
  284. return Array.from(map.values()).sort((a, b) => a.number - b.number);
  285. }
  286. // 动态抓取教务公布的当学期作息(公开同源接口,免登录)
  287. async function fetchTermTimeSlots(year, xq) {
  288. const url = HOST + '/public/SchoolTimetable.show.jsp';
  289. const body = 'xn=' + year + '&xq_m=' + xq + '&menucode=&is_ssxq=0&axq=&ssxq=';
  290. const resp = await fetch(url, {
  291. method: 'POST',
  292. headers: { 'content-type': 'application/x-www-form-urlencoded; charset=UTF-8' },
  293. body: body,
  294. credentials: 'include'
  295. });
  296. const buf = await resp.arrayBuffer();
  297. const text = decodeBest(buf);
  298. if (text.includes('未设置') || text.includes('未设定') || text.includes('未发布')) return null;
  299. const slots = parseTimetableHtml(text);
  300. return slots.length ? slots : null;
  301. }
  302. async function runImportFlow() {
  303. if (isLoginPage()) {
  304. window.shiguangBridge.showToast('请先登录教务系统,再点击导入!');
  305. return;
  306. }
  307. const confirmed = await promptUserToStart();
  308. if (!confirmed) return;
  309. const year = await selectYear();
  310. if (year === null) return;
  311. const semIdx = await selectSemester();
  312. if (semIdx === null || semIdx === -1) return;
  313. const html = await fetchScheduleHtml(year, semIdx);
  314. if (!html) return;
  315. if (html.includes('凭证已失效') || html.includes('请重新登录')) {
  316. window.shiguangBridge.showToast('登录已失效,请重新登录后重试。');
  317. return;
  318. }
  319. const courses = parseScheduleHtml(html);
  320. if (!courses || courses.length === 0) {
  321. window.shiguangBridge.showToast('未解析到课程,可能是本学期无课或页面结构已变化。');
  322. console.log('HIST: raw html head ->', (html || '').slice(0, 1200));
  323. return;
  324. }
  325. await window.shiguangBridgePromise.saveImportedCourses(JSON.stringify(courses, null, 2));
  326. window.shiguangBridge.showToast(`课程解析成功:${courses.length} 条`);
  327. // 作息与学年学期强相关:合并为一步,按所选学期自动抓取教务公布作息;失败用内置备用并提示
  328. const xq = semIdx === 0 ? 0 : 1;
  329. const termName = xq === 0 ? '第一学期' : '第二学期';
  330. let slots = null;
  331. try { slots = await fetchTermTimeSlots(year, xq); } catch (e) { slots = null; }
  332. if (slots && slots.length) {
  333. await window.shiguangBridgePromise.savePresetTimeSlots(JSON.stringify(slots));
  334. window.shiguangBridge.showToast(`已按 ${year}-${year + 1} 学年${termName}作息导入`);
  335. } else {
  336. const fallback = FALLBACK_TIMES[xq] || FALLBACK_TIMES[0];
  337. await window.shiguangBridgePromise.savePresetTimeSlots(JSON.stringify(fallback));
  338. window.shiguangBridge.showToast(`未能获取学校${termName}作息(可能未发布),已用备用作息,请核对`);
  339. }
  340. window.shiguangBridge.showToast(`导入完成,共 ${courses.length} 门课程!`);
  341. window.shiguangBridge.notifyTaskCompletion();
  342. }
  343. runImportFlow();