xsyu_01.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356
  1. // 西安石油大学(jiaowu.xsyu.edu.cn) 拾光课程表适配脚本
  2. // 教务系统:超星综合教学管理系统(dbname = xasydx)
  3. // 使用流程:登录教务系统 → 停留在任意教务页面 → 点击“执行导入”
  4. //
  5. // 接口:
  6. // GET /admin/pkgl/xskb/queryKbForXsd 课表页 HTML(#xhid / #xqdm / #xnxq1)
  7. // GET /admin/api/getZclistByXnxq?xnxq=<学期>&xqid=<校区代码> 周次表 zclist + 节次时间 jcsjszList
  8. // GET /admin/xsd/pkgl/xskb/sdpkkbList?xnxq=&xhid=&xqdm=&xskbxslx=0 课程数据(每行自带 xqid / xqmc)
  9. //
  10. // 要点:
  11. // 1. 课程按“小节”返回(djc 1~10,jcsjszList 同为 10 条),先按小节建课再合并连续节次与周次。
  12. // 2. kcmc / tmc / croommc 均为 <a> 包裹的 HTML;tmc 可能含多个 <a>,需整体取文本。
  13. // 3. sdpkkbList 的 xqdm 不生效(传任何校区都返回同一批课),课程行自带的 xqid / xqmc 才是上课校区;
  14. // getZclistByXnxq 的 xqid 生效,而三校区作息不同,故作息校区由课程数据推导。
  15. // 4. #xhid 必须取自课表页(86 字符加密串);首页 /admin 的同名元素是用户 ID,误用返回 ret=-1。
  16. //
  17. // 维护者:dr1am1
  18. // 学期选择:true = 弹窗选择(默认当前学期);false = 直接用当前学期
  19. const ASK_SEMESTER = true;
  20. const KB_PAGE_URL = '/admin/pkgl/xskb/queryKbForXsd';
  21. // 兜底作息(接口异常时使用),键为教务返回的校区名
  22. const FALLBACK_SLOTS = {
  23. '雁塔校区': ['08:00-08:50', '09:00-09:50', '10:20-11:00', '11:10-12:00', '14:30-15:20',
  24. '15:30-16:20', '16:40-17:30', '17:40-18:30', '19:30-20:20', '20:30-21:20'],
  25. '明德校区': ['08:00-08:50', '09:00-09:50', '10:10-11:00', '11:10-12:00', '14:30-15:20',
  26. '15:30-16:20', '16:40-17:30', '17:40-18:30', '19:30-20:20', '20:30-21:20'],
  27. '鄠邑校区': ['08:20-09:05', '09:15-10:00', '10:20-11:05', '11:15-12:00', '14:00-14:45',
  28. '14:55-15:40', '16:00-16:45', '16:55-17:40', '19:00-19:45', '19:55-20:40']
  29. };
  30. FALLBACK_SLOTS['默认校区'] = FALLBACK_SLOTS['鄠邑校区'];
  31. function fallbackSlots(campusName) {
  32. const list = FALLBACK_SLOTS[campusName] || FALLBACK_SLOTS['雁塔校区'];
  33. return list.map((item, i) => {
  34. const [startTime, endTime] = item.split('-');
  35. return { number: i + 1, startTime, endTime };
  36. });
  37. }
  38. // ============================== 桥接 ==============================
  39. function toast(message) {
  40. if (window.shiguangBridge && typeof window.shiguangBridge.showToast === 'function') {
  41. window.shiguangBridge.showToast(message);
  42. } else {
  43. console.log('[XSYU] ' + message);
  44. }
  45. }
  46. async function alertUser(title, content) {
  47. if (window.shiguangBridgePromise && typeof window.shiguangBridgePromise.showAlert === 'function') {
  48. return await window.shiguangBridgePromise.showAlert(title, content, '确定');
  49. }
  50. console.warn('[XSYU] ' + title + ' - ' + content);
  51. return true;
  52. }
  53. // ============================== 解析 ==============================
  54. // 取出 <a>包裹字段中的纯文本,如 <a ...>数字图像处理Ⅰ</a>
  55. function extractAnchorText(htmlStr) {
  56. if (!htmlStr) return '';
  57. const match = String(htmlStr).match(/>([^<]+)</);
  58. return match ? match[1].trim() : String(htmlStr).trim();
  59. }
  60. // 教师名可能是"<a>张三</a>,<a>李四</a>"或畸形串"<a>张三</a>,(,<a>李四</a>,)",
  61. // 后者括号内是有效教师而非备注,因此先取出括号内容再清括号,最后去重拼接
  62. function cleanTeacherName(htmlStr) {
  63. if (!htmlStr) return '';
  64. let text = String(htmlStr).replace(/<[^>]*>/g, '').replace(/&nbsp;/g, ' ').trim();
  65. const inner = [];
  66. text.replace(/[((]([^()()]*)[))]/g, (whole, group) => {
  67. group.split(/[,,、;;]/).forEach(t => {
  68. const name = t.trim();
  69. if (name && name.length <= 12) inner.push(name);
  70. });
  71. return whole;
  72. });
  73. text = text.replace(/([^)]*)/g, '').replace(/\([^)]*\)/g, '');
  74. const parts = [...text.split(/[,,、;;]/), ...inner].map(t => t.trim()).filter(Boolean);
  75. return [...new Set(parts)].join(',');
  76. }
  77. // zcstr 为逗号分隔的周次,如 "1,2,3,10,11"
  78. function parseWeeks(weekStr) {
  79. if (!weekStr) return [];
  80. return String(weekStr).split(',')
  81. .map(w => Number(String(w).trim()))
  82. .filter(w => w > 0)
  83. .sort((a, b) => a - b);
  84. }
  85. // "8:00" / "08:00:00" → "08:00"
  86. function formatTime(timeStr) {
  87. if (typeof timeStr !== 'string') return null;
  88. const match = timeStr.match(/^(\d{1,2}):(\d{1,2})(?::\d{1,2})?$/);
  89. if (!match) return null;
  90. return `${match[1].padStart(2, '0')}:${match[2].padStart(2, '0')}`;
  91. }
  92. // ============================== 网络 ==============================
  93. async function getJson(url) {
  94. const response = await fetch(url, {
  95. credentials: 'include',
  96. headers: { 'Accept': 'application/json, text/plain, */*', 'X-Requested-With': 'XMLHttpRequest' }
  97. });
  98. if (!response.ok) throw new Error(`网络请求失败,状态码 ${response.status}(${url.split('?')[0]})`);
  99. const json = await response.json();
  100. if (json.ret !== 0) throw new Error(`${url.split('?')[0]} 返回 ret=${json.ret}:${json.msg || '未知错误'}`);
  101. return json;
  102. }
  103. // 参数一律取自课表页:首页 /admin 的 #xhid 是用户 ID(32 字符),误用会返回 ret=-1
  104. async function extractPageParams() {
  105. const response = await fetch(KB_PAGE_URL, {
  106. credentials: 'include',
  107. headers: { 'Accept': 'text/html,application/xhtml+xml,*/*;q=0.8' }
  108. });
  109. if (!response.ok) throw new Error(`抓取课表页失败(${response.status}),请确认已登录教务系统`);
  110. const doc = new DOMParser().parseFromString(await response.text(), 'text/html');
  111. const xhid = doc.querySelector('#xhid')?.value || '';
  112. const xqdm = doc.querySelector('#xqdm')?.value || '';
  113. if (!xhid || !xqdm) throw new Error('课表页缺少 #xhid / #xqdm,请确认已登录教务系统');
  114. return { xhid, xqdm, xnxqSelectHtml: doc.querySelector('#xnxq1')?.outerHTML || null };
  115. }
  116. async function chooseSemester(selectHtml) {
  117. const select = selectHtml ? new DOMParser().parseFromString(selectHtml, 'text/html').querySelector('#xnxq1') : null;
  118. const options = select ? [...select.options].filter(o => o.value.trim()) : [];
  119. if (!options.length) throw new Error('未能读取学年学期下拉,请确认已登录教务系统');
  120. const current = Math.max(options.findIndex(o => o.selected), 0);
  121. if (!ASK_SEMESTER) return options[current].value.trim();
  122. const index = await window.shiguangBridgePromise.showSingleSelection(
  123. '选择学年学期', JSON.stringify(options.map(o => (o.textContent || o.value).trim())), current
  124. );
  125. if (index === null || index === -1) return null;
  126. return options[index].value.trim();
  127. }
  128. // sdpkkbList 的 xqdm 不过滤,只能靠课程行的 xqid / xqmc 判定上课校区;
  129. // 极少数跨校区选课的情况取课程最多者,作息以其为准
  130. function deriveCampus(rawRows, pageXqdm) {
  131. const tally = new Map();
  132. for (const raw of rawRows) {
  133. const xqdm = String(raw.xqid || '').trim();
  134. const xqmc = String(raw.xqmc || '').trim();
  135. if (!xqdm && !xqmc) continue;
  136. const item = tally.get(xqdm || xqmc) || { xqdm, xqmc, rows: 0 };
  137. item.rows += 1;
  138. tally.set(xqdm || xqmc, item);
  139. }
  140. const list = [...tally.values()].sort((a, b) => b.rows - a.rows);
  141. if (!list.length) return { xqdm: pageXqdm, xqmc: '' };
  142. if (list.length > 1) {
  143. console.warn(`JS: 本学期课程跨校区:${list.map(c => `${c.xqmc || c.xqdm}(${c.rows})`).join('、')}`);
  144. }
  145. return list[0];
  146. }
  147. // 节次时间必须从 1 连续且不重叠,否则返回 null 交由调用方回退内置作息
  148. function buildTimeSlots(list) {
  149. if (!Array.isArray(list) || !list.length) return null;
  150. const slots = list
  151. .map(item => ({ number: Number(item.jc), startTime: formatTime(item.kssj), endTime: formatTime(item.jssj) }))
  152. .filter(s => s.number > 0 && s.startTime && s.endTime)
  153. .sort((a, b) => a.number - b.number);
  154. const valid = slots.length > 0 && slots.every((s, i) => s.number === i + 1 && (i === 0 || s.startTime >= slots[i - 1].endTime));
  155. return valid ? slots : null;
  156. }
  157. async function fetchTimeAndWeekData(xnxq, xqdm) {
  158. const data = (await getJson(
  159. `/admin/api/getZclistByXnxq?xnxq=${encodeURIComponent(xnxq)}&xqid=${encodeURIComponent(xqdm)}`
  160. )).data || {};
  161. const zclist = Array.isArray(data.zclist) ? data.zclist : [];
  162. const firstWeek = zclist.find(z => Number(z.zc) === 1);
  163. return {
  164. timeSlots: buildTimeSlots(data.jcsjszList),
  165. // 第 1 周 minrq 即周一,与 semesterStartDate 语义一致
  166. semesterStartDate: firstWeek?.minrq ? String(firstWeek.minrq).split(' ')[0] : null,
  167. semesterTotalWeeks: zclist.length || 20
  168. };
  169. }
  170. // ============================== 课程 ==============================
  171. // 课程合并去重(参考 wiki《课程合并与去重函数》)
  172. function mergeAndDistinctCourses(courses) {
  173. if (!Array.isArray(courses) || courses.length <= 1) return courses;
  174. const list = courses.map(c => ({ ...c, weeks: [...c.weeks].sort((a, b) => a - b) }));
  175. const base = (a, b) => a.name.localeCompare(b.name) || a.teacher.localeCompare(b.teacher) ||
  176. a.position.localeCompare(b.position) || a.day - b.day;
  177. // 阶段 1:合并连续节次,剔除完全重复
  178. list.sort((a, b) => base(a, b) || a.weeks.join(',').localeCompare(b.weeks.join(',')) || a.startSection - b.startSection);
  179. const merged = [];
  180. let cur = list[0];
  181. for (const next of list.slice(1)) {
  182. const sameCourse = cur.name === next.name && cur.teacher === next.teacher &&
  183. cur.position === next.position && cur.day === next.day &&
  184. cur.weeks.join(',') === next.weeks.join(',');
  185. if (sameCourse && cur.endSection + 1 === next.startSection) {
  186. cur.endSection = next.endSection;
  187. } else if (sameCourse && cur.startSection === next.startSection && cur.endSection === next.endSection) {
  188. continue;
  189. } else {
  190. merged.push(cur);
  191. cur = next;
  192. }
  193. }
  194. merged.push(cur);
  195. // 阶段 2:合并同节次的周次
  196. merged.sort((a, b) => base(a, b) || a.startSection - b.startSection || a.endSection - b.endSection);
  197. const distinct = [];
  198. let prev = merged[0];
  199. for (const next of merged.slice(1)) {
  200. const sameSection = prev.name === next.name && prev.teacher === next.teacher &&
  201. prev.position === next.position && prev.day === next.day &&
  202. prev.startSection === next.startSection && prev.endSection === next.endSection;
  203. if (sameSection) {
  204. prev.weeks = [...new Set([...prev.weeks, ...next.weeks])].sort((a, b) => a - b);
  205. } else {
  206. distinct.push(prev);
  207. prev = next;
  208. }
  209. }
  210. distinct.push(prev);
  211. return distinct;
  212. }
  213. function parseCourseData(jsonData) {
  214. if (!Array.isArray(jsonData?.data)) return [];
  215. const list = [];
  216. for (const raw of jsonData.data) {
  217. const name = extractAnchorText(raw.kcmc);
  218. const day = Number(raw.xingqi);
  219. const section = Number(raw.djc);
  220. const weeks = parseWeeks(raw.zcstr);
  221. if (!name || !(day >= 1 && day <= 7) || !(section >= 1) || !weeks.length) continue;
  222. const course = {
  223. name,
  224. teacher: cleanTeacherName(raw.tmc),
  225. position: extractAnchorText(raw.croommc) || '待定',
  226. day,
  227. startSection: section,
  228. endSection: section,
  229. weeks
  230. };
  231. const credit = Number(raw.xf);
  232. if (credit > 0) course.credit = credit;
  233. list.push(course);
  234. }
  235. return mergeAndDistinctCourses(list);
  236. }
  237. async function fetchCourses(xnxq, xhid, xqdm) {
  238. const json = await getJson(
  239. `/admin/xsd/pkgl/xskb/sdpkkbList?xnxq=${encodeURIComponent(xnxq)}&xhid=${encodeURIComponent(xhid)}&xqdm=${encodeURIComponent(xqdm)}&xskbxslx=0`
  240. );
  241. return { courses: parseCourseData(json), rawRows: Array.isArray(json.data) ? json.data : [] };
  242. }
  243. // ============================== 保存 ==============================
  244. async function saveCourses(courses) {
  245. await window.shiguangBridgePromise.saveImportedCourses(JSON.stringify(courses));
  246. }
  247. async function saveTimeSlots(timeSlots) {
  248. await window.shiguangBridgePromise.savePresetTimeSlots(JSON.stringify(timeSlots));
  249. }
  250. async function saveConfig(semesterStartDate, semesterTotalWeeks) {
  251. const config = { semesterTotalWeeks };
  252. if (semesterStartDate) config.semesterStartDate = semesterStartDate;
  253. await window.shiguangBridgePromise.saveCourseConfig(JSON.stringify(config));
  254. }
  255. // ============================== 主流程 ==============================
  256. function isLoginPage() {
  257. return window.location.href.includes('/admin/login') || window.location.href.includes('slogin');
  258. }
  259. async function runImportFlow() {
  260. try {
  261. if (isLoginPage()) {
  262. await alertUser('导入失败', '当前是登录页面,请先完成登录并进入教务系统内部页面后再执行导入。');
  263. return;
  264. }
  265. const confirmed = await alertUser(
  266. '西安石油大学教务系统课表导入',
  267. '将自动读取当前学期、上课校区、作息时间与开学日期。\n导入前请确认已成功登录教务系统。'
  268. );
  269. if (confirmed !== true && confirmed !== 'true') {
  270. toast('用户取消了导入。');
  271. return;
  272. }
  273. const { xhid, xqdm, xnxqSelectHtml } = await extractPageParams();
  274. const xnxq = await chooseSemester(xnxqSelectHtml);
  275. if (!xnxq) {
  276. toast('导入已取消:未选择学年学期。');
  277. return;
  278. }
  279. toast(`正在导入 ${xnxq} ...`);
  280. const { courses, rawRows } = await fetchCourses(xnxq, xhid, xqdm);
  281. if (!courses.length) {
  282. await alertUser('未找到课程', `${xnxq} 学期未获取到任何课程数据,请确认学期选择是否正确。`);
  283. return;
  284. }
  285. await saveCourses(courses);
  286. // 作息校区取自课程数据,页面 #xqdm 与课程所在校区无关
  287. const campus = deriveCampus(rawRows, xqdm);
  288. const campusName = campus.xqmc || campus.xqdm || '未知校区';
  289. // 作息与开学日期:接口失败也要写入内置作息,避免沿用上次导入的遗留时间
  290. let extra = null;
  291. try {
  292. extra = await fetchTimeAndWeekData(xnxq, campus.xqdm);
  293. } catch (error) {
  294. console.warn('JS: 作息/开学日期获取失败:', error.message);
  295. toast('未获取到教务作息,已使用内置作息表。');
  296. }
  297. if (extra) await saveConfig(extra.semesterStartDate, extra.semesterTotalWeeks);
  298. await saveTimeSlots(extra?.timeSlots || fallbackSlots(campus.xqmc));
  299. toast(`导入成功:${courses.length} 条课程、${xnxq} · ${campusName}。`);
  300. window.shiguangBridge.notifyTaskCompletion();
  301. } catch (error) {
  302. console.error('JS: 导入流程异常:', error);
  303. await alertUser('导入失败', `${error.message}\n\n请确认:\n1. 已在教务系统中成功登录;\n2. 网络可以正常访问 jiaowu.xsyu.edu.cn。`);
  304. }
  305. }
  306. runImportFlow();