gztcme.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277
  1. // 贵州机电职业技术学院(gztcme.cn)正方 V9 教务适配
  2. // 入口:登录后进入「个人课表查询」,再执行导入。
  3. function gztcmeText(value) {
  4. return String(value == null ? '' : value).replace(/^\s+|\s+$/g, '');
  5. }
  6. function gztcmeConfirmed(value) {
  7. return value === true || value === 'true';
  8. }
  9. function gztcmeCheckPage() {
  10. if (window.location.pathname.indexOf('/jwglxt/') < 0) {
  11. throw new Error('请先在教务系统(jwxt.gztcme.cn)登录并进入“个人课表查询”,再执行导入。');
  12. }
  13. }
  14. async function gztcmeFetchText(path, body) {
  15. const init = { method: body ? 'POST' : 'GET', credentials: 'include' };
  16. if (body) {
  17. init.headers = { 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8' };
  18. init.body = body;
  19. }
  20. let response;
  21. try {
  22. response = await fetch('/jwglxt/' + path, init);
  23. } catch (error) {
  24. throw new Error('请求教务失败,请确认网络通畅并且已经登录教务系统。');
  25. }
  26. const text = await response.text();
  27. if ((response.url || '').indexOf('login_slogin') >= 0 || text.indexOf('login_slogin') >= 0) {
  28. throw new Error('登录已过期,请重新登录教务系统后再导入。');
  29. }
  30. if (!response.ok) throw new Error('教务返回异常(HTTP ' + response.status + '),请稍后重试。');
  31. return text;
  32. }
  33. async function gztcmeFetchJson(path, body) {
  34. const text = await gztcmeFetchText(path, body);
  35. try {
  36. return JSON.parse(text);
  37. } catch (error) {
  38. throw new Error('教务未返回有效数据,请重新登录后重试。');
  39. }
  40. }
  41. function gztcmeOptions(doc, selector) {
  42. const list = [];
  43. const nodes = doc.querySelectorAll(selector + ' option');
  44. for (let i = 0; i < nodes.length; i++) {
  45. const value = gztcmeText(nodes[i].getAttribute('value'));
  46. if (!value) continue;
  47. list.push({
  48. value: value,
  49. label: gztcmeText(nodes[i].textContent) || value,
  50. selected: nodes[i].selected === true || nodes[i].hasAttribute('selected')
  51. });
  52. }
  53. return list;
  54. }
  55. async function gztcmePick(title, items, defaultIndex) {
  56. const labels = [];
  57. for (let i = 0; i < items.length; i++) labels.push(items[i].label);
  58. const picked = await window.shiguangBridgePromise.showSingleSelection(title, JSON.stringify(labels), defaultIndex);
  59. if (picked === null || picked === undefined) return null;
  60. const text = String(picked).toLowerCase();
  61. if (text === 'null' || text === '') return null;
  62. const index = Number(picked);
  63. if (!(index >= 0 && index < items.length)) throw new Error('所选项目无效,请重新导入。');
  64. return items[index];
  65. }
  66. async function gztcmeChooseTerm() {
  67. const html = await gztcmeFetchText('kbcx/xskbcx_cxXskbcxIndex.html?gnmkdm=N2151&layout=default');
  68. const doc = new DOMParser().parseFromString(html, 'text/html');
  69. const years = gztcmeOptions(doc, '#xnm');
  70. const terms = gztcmeOptions(doc, '#xqm');
  71. if (years.length === 0 || terms.length === 0) throw new Error('未取得学年学期列表,请确认已经登录教务系统。');
  72. let yearIndex = 0, termIndex = 0;
  73. for (let i = 0; i < years.length; i++) if (years[i].selected) yearIndex = i;
  74. for (let i = 0; i < terms.length; i++) if (terms[i].selected) termIndex = i;
  75. const year = await gztcmePick('选择学年', years, yearIndex);
  76. if (!year) return null;
  77. const term = await gztcmePick('选择学期', terms, termIndex);
  78. if (!term) return null;
  79. return { year: year.value, term: term.value, label: year.label + ' 第 ' + term.label + ' 学期' };
  80. }
  81. function gztcmeWeeks(value) {
  82. const text = gztcmeText(value).replace(/[第周\s]/g, '').replace(/(/g, '(').replace(/)/g, ')').replace(/[,、]/g, ',');
  83. const weeks = [], seen = {};
  84. const parts = text.split(',');
  85. for (let i = 0; i < parts.length; i++) {
  86. if (!parts[i]) continue;
  87. const match = parts[i].match(/^(\d+)(?:-(\d+))?(?:\(([单双])\))?$/);
  88. if (!match) throw new Error('无法识别上课周次「' + value + '」,请核对教务课表。');
  89. const from = Number(match[1]), to = Number(match[2] || match[1]);
  90. if (from < 1 || to < from || to > 60) throw new Error('上课周次超出有效范围。');
  91. for (let week = from; week <= to; week++) {
  92. if (match[3] === '单' && week % 2 === 0) continue;
  93. if (match[3] === '双' && week % 2 === 1) continue;
  94. if (!seen[week]) { seen[week] = true; weeks.push(week); }
  95. }
  96. }
  97. weeks.sort(function (a, b) { return a - b; });
  98. if (weeks.length === 0) throw new Error('课程周次为空,请核对教务课表。');
  99. return weeks;
  100. }
  101. function gztcmeCourses(data, term) {
  102. if (!data || !Array.isArray(data.kbList) || !data.xsxx) throw new Error('教务未返回课表数据,请重新进入个人课表页面。');
  103. if (gztcmeText(data.xsxx.XNM) !== term.year || gztcmeText(data.xsxx.XQM) !== term.term) {
  104. throw new Error('课表与所选学期不一致,请重新导入。');
  105. }
  106. const courses = [], campuses = [];
  107. for (let i = 0; i < data.kbList.length; i++) {
  108. const row = data.kbList[i];
  109. const name = gztcmeText(row.kcmc);
  110. if (!name) continue;
  111. const day = Number(row.xqj);
  112. const section = gztcmeText(row.jcs).replace(/节/g, '').match(/^(\d+)(?:-(\d+))?$/);
  113. if (!(day >= 1 && day <= 7) || !section) throw new Error('课程「' + name + '」的星期或节次异常,请核对教务课表。');
  114. const startSection = Number(section[1]), endSection = Number(section[2] || section[1]);
  115. if (startSection < 1 || endSection < startSection) throw new Error('课程「' + name + '」的节次异常,请核对教务课表。');
  116. courses.push({
  117. name: name,
  118. teacher: gztcmeText(row.xm),
  119. position: gztcmeText(row.cdmc),
  120. day: day,
  121. startSection: startSection,
  122. endSection: endSection,
  123. weeks: gztcmeWeeks(row.zcd)
  124. });
  125. const campusId = gztcmeText(row.xqh_id);
  126. if (campusId) {
  127. let known = false;
  128. for (let j = 0; j < campuses.length; j++) if (campuses[j].value === campusId) known = true;
  129. if (!known) campuses.push({ value: campusId, label: gztcmeText(row.xqmc) || '校区' });
  130. }
  131. }
  132. if (courses.length === 0) throw new Error('该学期没有可导入的课程,请确认所选学期。');
  133. return { courses: courses, campuses: campuses, unscheduled: Array.isArray(data.sjkList) ? data.sjkList : [] };
  134. }
  135. function gztcmeCalendar(rows) {
  136. if (!Array.isArray(rows) || rows.length === 0) throw new Error('未取得所选学期的校历,请确认学期选择。');
  137. const weeks = [];
  138. for (let i = 0; i < rows.length; i++) {
  139. const number = Number(rows[i].zs);
  140. const match = gztcmeText(rows[i].rq).match(/^(\d{4}-\d{2}-\d{2})\/(\d{4}-\d{2}-\d{2})$/);
  141. if (!(number >= 1) || !match) throw new Error('校历格式异常,无法确定开学日期。');
  142. weeks.push({ number: number, start: match[1], end: match[2] });
  143. }
  144. weeks.sort(function (a, b) { return a.number - b.number; });
  145. if (weeks.length > 60) throw new Error('校历周数超出有效范围。');
  146. const first = new Date(weeks[0].start + 'T00:00:00Z');
  147. if (isNaN(first.getTime()) || first.getUTCDay() !== 1) throw new Error('校历第一周不是周一,无法确定开学日期。');
  148. for (let i = 0; i < weeks.length; i++) {
  149. const start = new Date(weeks[i].start + 'T00:00:00Z');
  150. const end = new Date(weeks[i].end + 'T00:00:00Z');
  151. if (weeks[i].number !== i + 1) throw new Error('校历周次不连续。');
  152. if (isNaN(start.getTime()) || isNaN(end.getTime()) || end < start) throw new Error('校历日期格式异常。');
  153. if (start.getTime() - first.getTime() !== i * 7 * 86400000) throw new Error('校历日期与教学周不对应。');
  154. }
  155. return { semesterStartDate: weeks[0].start, semesterTotalWeeks: weeks.length, firstDayOfWeek: 1 };
  156. }
  157. function gztcmeClock(value) {
  158. const match = gztcmeText(value).match(/^(\d{1,2}):(\d{2})/);
  159. if (!match) throw new Error('作息时间格式异常。');
  160. return (match[1].length === 1 ? '0' + match[1] : match[1]) + ':' + match[2];
  161. }
  162. function gztcmeTimeSlots(rows) {
  163. const slots = [], seen = {};
  164. for (let i = 0; i < rows.length; i++) {
  165. const number = Number(rows[i].jcmc);
  166. if (!(number >= 1) || seen[number]) continue;
  167. seen[number] = true;
  168. slots.push({ number: number, startTime: gztcmeClock(rows[i].qssj), endTime: gztcmeClock(rows[i].jssj) });
  169. }
  170. slots.sort(function (a, b) { return a.number - b.number; });
  171. for (let i = 0; i < slots.length; i++) {
  172. if (slots[i].number !== i + 1) throw new Error('作息节次编号不连续,请核对教务作息。');
  173. if (slots[i].startTime >= slots[i].endTime) throw new Error('作息起止时间异常,请核对教务作息。');
  174. }
  175. return slots;
  176. }
  177. async function gztcmeSave(method, value, label) {
  178. const result = await window.shiguangBridgePromise[method](JSON.stringify(value));
  179. if (!gztcmeConfirmed(result)) throw new Error(label + '保存未成功,请重新导入。');
  180. }
  181. async function gztcmeRunImport() {
  182. let savedStage = '';
  183. try {
  184. gztcmeCheckPage();
  185. window.shiguangBridge.showToast('正在读取学年学期…');
  186. const term = await gztcmeChooseTerm();
  187. if (!term) { window.shiguangBridge.showToast('导入已取消'); return; }
  188. window.shiguangBridge.showToast('正在读取课表、校历和作息…');
  189. const fetched = await Promise.all([
  190. gztcmeFetchJson('kbcx/xskbcx_cxXsgrkb.html?gnmkdm=N2151', 'xnm=' + term.year + '&xqm=' + term.term + '&kzlx=ck'),
  191. gztcmeFetchJson('kbcx/xskbcxZccx_cxZcByXnxq.html?gnmkdm=N2154', 'xnm=' + term.year + '&xqm=' + term.term)
  192. ]);
  193. const parsed = gztcmeCourses(fetched[0], term);
  194. const config = gztcmeCalendar(fetched[1]);
  195. let campus = parsed.campuses.length > 0 ? parsed.campuses[0] : null;
  196. if (parsed.campuses.length > 1) {
  197. campus = await gztcmePick('选择课表作息校区', parsed.campuses, 0);
  198. if (!campus) { window.shiguangBridge.showToast('导入已取消'); return; }
  199. }
  200. let slots = [];
  201. if (campus) {
  202. const rows = await gztcmeFetchJson('kbcx/xskbcx_cxRjc.html?gnmkdm=N2151',
  203. 'xnm=' + term.year + '&xqm=' + term.term + '&xqh_id=' + campus.value);
  204. slots = gztcmeTimeSlots(Array.isArray(rows) ? rows : []);
  205. }
  206. let maxSection = 0;
  207. const names = [];
  208. for (let i = 0; i < parsed.courses.length; i++) {
  209. const course = parsed.courses[i];
  210. if (course.endSection > maxSection) maxSection = course.endSection;
  211. for (let j = 0; j < course.weeks.length; j++) {
  212. if (course.weeks[j] > config.semesterTotalWeeks) {
  213. throw new Error('课程「' + course.name + '」周次超出本学期校历(' + config.semesterTotalWeeks + ' 周)。');
  214. }
  215. }
  216. let known = false;
  217. for (let j = 0; j < names.length; j++) if (names[j] === course.name) known = true;
  218. if (!known) names.push(course.name);
  219. }
  220. if (slots.length > 0 && maxSection > slots.length) throw new Error('课表节次超出作息节数,请核对教务课表。');
  221. let note = term.label + '\n' +
  222. '课程时段:' + parsed.courses.length + ' 条 / ' + names.length + ' 门课\n' +
  223. '开学日期:' + config.semesterStartDate + '(第 1 周周一)\n' +
  224. '校历周数:' + config.semesterTotalWeeks + ' 周\n' +
  225. '作息节数:' + (slots.length > 0 ? slots.length + ' 节(' + (campus ? campus.label : '') + ')' : '未取到,使用应用默认作息');
  226. if (parsed.unscheduled.length > 0) {
  227. let pending = '';
  228. for (let i = 0; i < parsed.unscheduled.length; i++) {
  229. const row = parsed.unscheduled[i];
  230. pending += '\n· ' + (gztcmeText(row.kcmc) || '未命名课程') + '(' + (gztcmeText(row.qsjsz) || '周次未知') + ')';
  231. }
  232. note += '\n\n以下 ' + parsed.unscheduled.length + ' 门课尚未排定上课时间,本次不导入:' + pending;
  233. }
  234. const confirmed = await window.shiguangBridgePromise.showAlert('核对导入信息', note, '确认导入');
  235. if (!gztcmeConfirmed(confirmed)) { window.shiguangBridge.showToast('导入已取消'); return; }
  236. await gztcmeSave('saveCourseConfig', config, '学期配置');
  237. savedStage = '学期配置';
  238. if (slots.length > 0) {
  239. await gztcmeSave('savePresetTimeSlots', slots, '作息');
  240. savedStage = '学期配置和作息';
  241. }
  242. await gztcmeSave('saveImportedCourses', parsed.courses, '课程');
  243. window.shiguangBridge.showToast('已导入 ' + parsed.courses.length + ' 条课程时段');
  244. window.shiguangBridge.notifyTaskCompletion();
  245. } catch (error) {
  246. const message = error && error.message ? error.message : String(error);
  247. await window.shiguangBridgePromise.showAlert('导入未完成',
  248. (savedStage ? savedStage + '已保存,后续步骤未完成,请重新导入。\n' : '') + message, '知道了');
  249. }
  250. }
  251. gztcmeRunImport();