just.js 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464
  1. // 江苏科技大学(just.edu.cn) 拾光课程表适配脚本
  2. // 教务系统:正方新一代教务(/jwglxt)
  3. // 校外访问:深信服 enlink WebVPN,统一身份认证 https://client.v.just.edu.cn/
  4. // WebVPN 代理路径形如 /http/webvpn<hex>/jwglxt/...,<hex> 与登录会话相关不能写死,
  5. // 脚本从当前页面 URL 动态取前缀。
  6. // 课表页的学年/学期是 <select id="xnm">(学年)/ <select id="xqm">(学期),
  7. // 被 chosen 组件包装成 div#xnm_chosen / div#xqm_chosen(原 select 是容器的前一个兄弟节点)。
  8. // 脚本读出选项让用户确认,再请求课表、校历与校区作息。
  9. // 维护者:abyss-stars
  10. (async function () {
  11. const bridge = window.shiguangBridgePromise;
  12. const native = window.shiguangBridge;
  13. if (!bridge || !native) return;
  14. if (window.__justImportRunning) {
  15. native.showToast('课表正在导入,请勿重复点击。');
  16. return;
  17. }
  18. window.__justImportRunning = true;
  19. const GNMKDM = 'N2151';
  20. const API = {
  21. course: '/kbcx/xskbcx_cxXsgrkb.html',
  22. calendar: '/kbcx/xskbcxZccx_cxZcByXnxq.html',
  23. timeSlots: '/kbcx/xskbcx_cxRjc.html'
  24. };
  25. // ---------- 1. 接口 ----------
  26. // 校内直连是 https://<host>/jwglxt/...,WebVPN 下前面还有 /http/webvpn<hex>(<hex> 与会话相关)
  27. const matched = (window.location.pathname || '').match(/^(.*?)\/jwglxt(?:\/|$)/i);
  28. const BASE = (matched ? matched[1] : '') + '/jwglxt';
  29. // enlink 网关下通常需要 enlink-vpn 标记,两种写法依次尝试
  30. async function request(modulePath, params) {
  31. const body = Object.keys(params)
  32. .map((key) => `${encodeURIComponent(key)}=${encodeURIComponent(params[key] == null ? '' : params[key])}`)
  33. .join('&');
  34. for (const suffix of [`?gnmkdm=${GNMKDM}&enlink-vpn`, `?gnmkdm=${GNMKDM}`]) {
  35. try {
  36. const response = await fetch(BASE + modulePath + suffix, {
  37. method: 'POST',
  38. credentials: 'include',
  39. headers: {
  40. 'X-Requested-With': 'XMLHttpRequest',
  41. 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8'
  42. },
  43. body: body
  44. });
  45. if (!response.ok) continue;
  46. const raw = await response.text();
  47. const head = raw.replace(/^\s+/, '').charAt(0);
  48. // 非 JSON(例如被跳到登录页)就换下一种写法
  49. if (head === '{' || head === '[') return JSON.parse(raw);
  50. } catch (error) {
  51. // 换下一种写法
  52. }
  53. }
  54. return null;
  55. }
  56. // ---------- 2. 解析 ----------
  57. const text = (value) => String(value == null ? '' : value).replace(/^\s+|\s+$/g, '');
  58. // 周次:"1-16周"、"1-16周(单)"、"1,3,5-9周" 等
  59. function parseWeeks(value) {
  60. const weeks = new Set();
  61. const normalized = text(value).replace(/\s/g, '').replace(/[,、;;]/g, ',')
  62. .replace(/(/g, '(').replace(/)/g, ')');
  63. for (const part of normalized.split(',')) {
  64. const odds = /单/.test(part);
  65. const evens = /双/.test(part);
  66. const digits = part.replace(/[^\d-]/g, '');
  67. const matched = digits.match(/^(\d+)-(\d+)$/) || digits.match(/^(\d+)$/);
  68. if (!matched) continue;
  69. const start = Number(matched[1]);
  70. const end = Number(matched[2] || matched[1]);
  71. if (!(start > 0) || end < start) continue;
  72. for (let week = start; week <= end; week++) {
  73. if (odds && week % 2 === 0) continue;
  74. if (evens && week % 2 !== 0) continue;
  75. weeks.add(week);
  76. }
  77. }
  78. return [...weeks].sort((a, b) => a - b);
  79. }
  80. // 节次:"1-2"、"1"、"0102"(部分部署补零)
  81. function parseSections(value) {
  82. const raw = text(value);
  83. const hyphen = raw.match(/(\d+)\s*[-~—-]\s*(\d+)/);
  84. if (hyphen) return [Number(hyphen[1]), Number(hyphen[2])];
  85. const digits = raw.replace(/\D/g, '');
  86. if (!digits) return null;
  87. if (digits.length === 4) return [Number(digits.slice(0, 2)), Number(digits.slice(2))];
  88. return [Number(digits), Number(digits)];
  89. }
  90. function toMinutes(value) {
  91. const matched = String(value == null ? '' : value).match(/^(\d{1,2}):(\d{2})$/);
  92. return matched ? Number(matched[1]) * 60 + Number(matched[2]) : -1;
  93. }
  94. // 统一成 app 要求的 HH:mm
  95. function padTime(value) {
  96. const matched = String(value == null ? '' : value).match(/(\d{1,2}):(\d{2})/);
  97. if (!matched) return '';
  98. const hour = Number(matched[1]);
  99. return hour > 23 ? '' : `${hour < 10 ? '0' : ''}${hour}:${matched[2]}`;
  100. }
  101. // 一条接口记录 -> 排课条目;没有固定星期/节次的(实践、网络课程等)返回 null
  102. function toCourseEntry(raw) {
  103. const name = text(raw.kcmc);
  104. const day = Number(text(raw.xqj));
  105. const weeks = parseWeeks(raw.zcd);
  106. const sections = parseSections(text(raw.jcs) !== '' ? raw.jcs : raw.jc);
  107. if (!name || !(day >= 1 && day <= 7) || !weeks.length || !sections) return null;
  108. if (!(sections[0] > 0) || sections[1] < sections[0]) return null;
  109. return {
  110. campusId: text(raw.xqh_id) || '0',
  111. campusName: text(raw.xqmc),
  112. course: {
  113. name: name,
  114. teacher: text(raw.xm) || '未知教师',
  115. position: text(raw.cdmc) || text(raw.cdbh) || '未排地点',
  116. day: day,
  117. startSection: sections[0],
  118. endSection: sections[1],
  119. weeks: weeks
  120. }
  121. };
  122. }
  123. // kbList 与 sjkList 通用:只取有固定星期与节次的记录,其余(实践课程、网络课程等)忽略
  124. function collectCourses(list) {
  125. const entries = [];
  126. for (const raw of (Array.isArray(list) ? list : [])) {
  127. const entry = toCourseEntry(raw);
  128. if (entry) entries.push(entry);
  129. }
  130. return entries;
  131. }
  132. // 校区作息:jcmc=节次,qssj/jssj=起止时间
  133. function parseTimeSlots(rows) {
  134. const slots = [];
  135. for (const row of (Array.isArray(rows) ? rows : [])) {
  136. const number = Number(text(row.jcmc != null ? row.jcmc : row.jcdm));
  137. const startTime = padTime(row.qssj);
  138. const endTime = padTime(row.jssj);
  139. if (!(number > 0) || !startTime || !endTime) continue;
  140. if (toMinutes(startTime) >= toMinutes(endTime)) continue;
  141. slots.push({ number: number, startTime: startTime, endTime: endTime });
  142. }
  143. slots.sort((a, b) => a.number - b.number);
  144. // app 要求节次从 1 连续,否则整段丢弃
  145. const continuous = slots.length > 0 && (() => {
  146. for (let i = 0; i < slots.length; i++) {
  147. if (slots[i].number !== i + 1) return false;
  148. }
  149. return true;
  150. })();
  151. return continuous ? slots : [];
  152. }
  153. // 学期校历:zs=周次,rq="起始日/结束日"
  154. function parseCalendar(rows) {
  155. const weeks = [];
  156. for (const row of (Array.isArray(rows) ? rows : [])) {
  157. const number = Number(text(row.zs != null ? row.zs : row.zsmc));
  158. const start = text(row.rq || row.zcrq || row.ksrq).split('/')[0];
  159. if (number > 0 && /^\d{4}-\d{2}-\d{2}$/.test(start)) weeks.push({ number: number, start: start });
  160. }
  161. if (!weeks.length) return null;
  162. weeks.sort((a, b) => a.number - b.number);
  163. const first = weeks[0];
  164. return {
  165. startDate: first.start,
  166. // app 的 firstDayOfWeek 是 1=周一 … 7=周日
  167. firstDayOfWeek: (new Date(`${first.start}T00:00:00Z`).getUTCDay() + 6) % 7 + 1,
  168. totalWeeks: weeks[weeks.length - 1].number
  169. };
  170. }
  171. // 教务处公布的作息时间表(接口取不到时兜底)
  172. const FALLBACK_TIME_SLOTS = {
  173. '长山': [[1, '08:30', '09:15'], [2, '09:20', '10:05'], [3, '10:25', '11:10'], [4, '11:15', '12:00'],
  174. [5, '14:00', '14:45'], [6, '14:50', '15:35'], [7, '15:55', '16:40'], [8, '16:45', '17:30'],
  175. [9, '18:30', '19:15'], [10, '19:20', '20:05']],
  176. '梦溪': [[1, '08:00', '08:45'], [2, '08:55', '09:40'], [3, '10:00', '10:45'], [4, '10:55', '11:40'],
  177. [5, '14:00', '14:45'], [6, '14:55', '15:40'], [7, '15:50', '16:35'], [8, '16:45', '17:30'],
  178. [9, '19:00', '19:45'], [10, '19:55', '20:40'], [11, '20:50', '21:35']],
  179. // 张家港取自 2026-2027-1 学期 xskbcx_cxRjc 接口实测(与梦溪不同)
  180. '张家港': [[1, '08:00', '08:45'], [2, '08:55', '09:40'], [3, '10:00', '10:45'], [4, '10:55', '11:40'],
  181. [5, '14:00', '14:45'], [6, '14:55', '15:40'], [7, '16:00', '16:45'], [8, '16:55', '17:40'],
  182. [9, '19:00', '19:45'], [10, '19:55', '20:40'], [11, '20:50', '21:35']]
  183. };
  184. function fallbackSlots(campusName) {
  185. for (const key of Object.keys(FALLBACK_TIME_SLOTS)) {
  186. if (new RegExp(key).test(campusName)) {
  187. return FALLBACK_TIME_SLOTS[key].map((item) => ({ number: item[0], startTime: item[1], endTime: item[2] }));
  188. }
  189. }
  190. return null;
  191. }
  192. // 节次与周次合并去重(参考 wiki《课程合并与去重函数》)
  193. function mergeCourses(courses) {
  194. if (courses.length <= 1) return courses;
  195. const sameCourse = (a, b) =>
  196. a.name === b.name && a.teacher === b.teacher && a.position === b.position && a.day === b.day &&
  197. !!a.isCustomTime === !!b.isCustomTime &&
  198. (!a.isCustomTime || (a.customStartTime === b.customStartTime && a.customEndTime === b.customEndTime));
  199. const list = courses.map((course) => Object.assign({}, course, {
  200. weeks: [...course.weeks].sort((a, b) => a - b)
  201. }));
  202. list.sort((a, b) =>
  203. a.name.localeCompare(b.name) || a.teacher.localeCompare(b.teacher) ||
  204. a.position.localeCompare(b.position) || a.day - b.day ||
  205. a.weeks.join(',').localeCompare(b.weeks.join(',')) || a.startSection - b.startSection);
  206. const merged = [];
  207. let current = list[0];
  208. for (let i = 1; i < list.length; i++) {
  209. const next = list[i];
  210. const mergeable = sameCourse(current, next) && !current.isCustomTime &&
  211. current.weeks.join(',') === next.weeks.join(',');
  212. if (mergeable && current.endSection + 1 === next.startSection) {
  213. current.endSection = next.endSection; // 连续节次:1-2 + 3-4 -> 1-4
  214. } else if (mergeable && current.startSection === next.startSection &&
  215. current.endSection === next.endSection) {
  216. continue; // 完全重复
  217. } else {
  218. merged.push(current);
  219. current = next;
  220. }
  221. }
  222. merged.push(current);
  223. // 同节次的周次合并(单周 + 双周 -> 全周)
  224. merged.sort((a, b) =>
  225. a.name.localeCompare(b.name) || a.teacher.localeCompare(b.teacher) ||
  226. a.position.localeCompare(b.position) || a.day - b.day ||
  227. a.startSection - b.startSection || a.endSection - b.endSection);
  228. const result = [];
  229. let head = merged[0];
  230. for (let i = 1; i < merged.length; i++) {
  231. const next = merged[i];
  232. if (sameCourse(head, next) && head.startSection === next.startSection &&
  233. head.endSection === next.endSection) {
  234. head.weeks = [...new Set([...head.weeks, ...next.weeks])].sort((a, b) => a - b);
  235. } else {
  236. result.push(head);
  237. head = next;
  238. }
  239. }
  240. result.push(head);
  241. return result;
  242. }
  243. // ---------- 3. 学年学期 ----------
  244. // 学年/学期是 <select id="xnm"> / <select id="xqm">,chosen 组件会在其后插入 div#xnm_chosen 容器
  245. function findTermSelect(name) {
  246. const select = document.getElementById(name);
  247. if (select) return select;
  248. const chosen = document.getElementById(name + '_chosen');
  249. const sibling = chosen ? chosen.previousElementSibling : null;
  250. return sibling && sibling.tagName === 'SELECT' ? sibling : null;
  251. }
  252. function readSelectOptions(name) {
  253. const select = findTermSelect(name);
  254. const options = [];
  255. if (!select) return options;
  256. // 注意:教务页面改写了 Array.prototype.filter/some/every(回调实参变成下标),
  257. // 所以这里全部用普通循环,不调用这些方法。
  258. for (const option of Array.from(select.options)) {
  259. const value = text(option.value);
  260. if (value === '') continue;
  261. options.push({
  262. value: value,
  263. text: text(option.textContent) || value,
  264. selected: option.selected === true
  265. });
  266. }
  267. return options;
  268. }
  269. // 默认选中教务当前学年学期,没有标记则取第一项
  270. function defaultIndex(options) {
  271. for (let i = 0; i < options.length; i++) {
  272. if (options[i].selected) return i;
  273. }
  274. return 0;
  275. }
  276. async function selectTerm() {
  277. const yearOptions = readSelectOptions('xnm');
  278. const semesterOptions = readSelectOptions('xqm');
  279. if (!yearOptions.length || !semesterOptions.length) {
  280. await bridge.showAlert('读取学年学期失败',
  281. '未在页面中找到学年(.xnm)/学期(.xqm)下拉。\n请确认:\n' +
  282. '1. 已登录教务系统(校外需先登录 WebVPN);\n' +
  283. '2. 当前停留在「信息查询-学生课表查询」页面。', '知道了');
  284. return null;
  285. }
  286. const yearIndex = await bridge.showSingleSelection('选择学年',
  287. JSON.stringify(yearOptions.map((item) => item.text)), defaultIndex(yearOptions));
  288. if (yearIndex === null || yearIndex === -1) return null;
  289. const semesterIndex = await bridge.showSingleSelection('选择学期',
  290. JSON.stringify(semesterOptions.map((item) => item.text)), defaultIndex(semesterOptions));
  291. if (semesterIndex === null || semesterIndex === -1) return null;
  292. return {
  293. xnm: yearOptions[yearIndex].value,
  294. xnmText: yearOptions[yearIndex].text,
  295. xqm: semesterOptions[semesterIndex].value,
  296. xqmText: semesterOptions[semesterIndex].text
  297. };
  298. }
  299. // 返回该校区节次作息数组;接口取不到时用教务处公布的作息,未知校区返回 null(跳过作息导入)
  300. async function fetchTimeSlots(campus, term) {
  301. if (campus.id !== '0') {
  302. const slots = parseTimeSlots(await request(API.timeSlots,
  303. Object.assign({ xqh_id: campus.id }, term)));
  304. if (slots.length) return slots;
  305. }
  306. return fallbackSlots(campus.name);
  307. }
  308. // ---------- 4. 主流程 ----------
  309. try {
  310. if (!await bridge.showAlert('江苏科技大学课表导入',
  311. '导入前请确保已登录教务系统。\n校外需先登录 WebVPN(client.v.just.edu.cn),' +
  312. '并进入教务系统页面(如「信息查询-学生课表查询」)。', '好的,开始导入')) {
  313. native.showToast('用户取消了导入。');
  314. return;
  315. }
  316. const term = await selectTerm();
  317. if (!term) {
  318. native.showToast('导入已取消。');
  319. return;
  320. }
  321. native.showToast('正在获取课表数据…');
  322. const params = { xnm: term.xnm, xqm: term.xqm };
  323. const [courseData, calendarRows] = await Promise.all([
  324. request(API.course, Object.assign({ kzlx: 'ck', xsdm: '', kclbdm: '', kclxdm: '' }, params)),
  325. request(API.calendar, params)
  326. ]);
  327. const calendar = parseCalendar(calendarRows);
  328. if (!courseData || !Array.isArray(courseData.kbList)) {
  329. await bridge.showAlert('获取课表失败',
  330. '教务系统未返回课表数据,请确认已登录、且当前在教务系统页面内。', '知道了');
  331. return;
  332. }
  333. // sjkList 是实践/集中教学安排,其中带星期与节次的同样可以排进课表
  334. const entries = collectCourses(courseData.kbList).concat(collectCourses(courseData.sjkList));
  335. if (!entries.length) {
  336. await bridge.showAlert('没有可导入的课程', '所选学期没有解析出已排课程。', '知道了');
  337. return;
  338. }
  339. const campusMap = new Map();
  340. for (const entry of entries) {
  341. const campus = campusMap.get(entry.campusId) ||
  342. { id: entry.campusId, name: entry.campusName, count: 0 };
  343. campus.count++;
  344. campusMap.set(entry.campusId, campus);
  345. }
  346. const campuses = [...campusMap.values()].sort((a, b) => b.count - a.count);
  347. native.showToast('正在获取校区作息…');
  348. const slotsMap = new Map();
  349. await Promise.all(campuses.map(async (campus) => {
  350. slotsMap.set(campus.id, await fetchTimeSlots(campus, params));
  351. }));
  352. let campusIndex = 0;
  353. if (campuses.length > 1) {
  354. const index = await bridge.showSingleSelection('选择默认作息校区',
  355. JSON.stringify(campuses.map((campus) => `${campus.name || campus.id}(${campus.count} 条)`)), 0);
  356. if (index === null || index === -1) {
  357. native.showToast('导入已取消。');
  358. return;
  359. }
  360. campusIndex = index;
  361. }
  362. const presetCampus = campuses[campusIndex];
  363. const presetSlots = slotsMap.get(presetCampus.id) || null;
  364. // 与默认作息不同的校区,课程改用自定义时间
  365. const courses = entries.map((entry) => {
  366. const course = Object.assign({}, entry.course);
  367. const slots = slotsMap.get(entry.campusId) || presetSlots;
  368. if (presetSlots && slots && JSON.stringify(slots) !== JSON.stringify(presetSlots)) {
  369. const first = slots[course.startSection - 1];
  370. const last = slots[course.endSection - 1];
  371. if (first && last) {
  372. course.isCustomTime = true;
  373. course.customStartTime = first.startTime;
  374. course.customEndTime = last.endTime;
  375. }
  376. }
  377. return course;
  378. });
  379. const merged = mergeCourses(courses);
  380. // 课表配置
  381. const maxWeek = Math.max(0, ...merged.map((course) => Math.max(...course.weeks)));
  382. const config = { semesterTotalWeeks: Math.max(calendar ? calendar.totalWeeks : 0, maxWeek, 1) };
  383. if (calendar) {
  384. config.semesterStartDate = calendar.startDate;
  385. config.firstDayOfWeek = calendar.firstDayOfWeek;
  386. }
  387. const firstWeekday = Number(text(courseData.qsxqj));
  388. if (firstWeekday >= 1 && firstWeekday <= 7) config.firstDayOfWeek = firstWeekday;
  389. if (presetSlots) {
  390. const classDuration = toMinutes(presetSlots[0].endTime) - toMinutes(presetSlots[0].startTime);
  391. if (classDuration > 0) config.defaultClassDuration = classDuration;
  392. if (presetSlots.length > 1) {
  393. const breakDuration = toMinutes(presetSlots[1].startTime) - toMinutes(presetSlots[0].endTime);
  394. if (breakDuration > 0) config.defaultBreakDuration = breakDuration;
  395. }
  396. }
  397. await bridge.saveImportedCourses(JSON.stringify(merged));
  398. if (presetSlots) await bridge.savePresetTimeSlots(JSON.stringify(presetSlots));
  399. await bridge.saveCourseConfig(JSON.stringify(config));
  400. // 课表下方"未确认上课节次"的安排(实践课程、网络课程等)无法排进课表,直接忽略;
  401. // 只有本校区作息没取到时才提示一次
  402. if (!presetSlots) {
  403. await bridge.showAlert('导入完成',
  404. `${term.xnmText} 学年第 ${term.xqmText} 学期:已导入 ${merged.length} 条排课记录。\n\n` +
  405. '未能读取本校区节次作息,已跳过作息导入,请在应用内手动设置节次时间。', '知道了');
  406. }
  407. native.showToast(`课程导入成功,共导入 ${merged.length} 条排课记录!`);
  408. native.notifyTaskCompletion();
  409. } catch (error) {
  410. await bridge.showAlert('江苏科技大学课表导入失败', (error && error.message) || String(error), '知道了');
  411. } finally {
  412. delete window.__justImportRunning;
  413. }
  414. })();