qqhrit.js 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590
  1. // 齐齐哈尔工程学院(qqhrit.com) 拾光课程表适配脚本
  2. // 基于正方教务系统接口适配
  3. // 非该大学开发者适配,开发者无法及时发现问题
  4. // 出现问题请提issues或者提交pr更改,这更加快速
  5. /**
  6. * 基础地址
  7. */
  8. const BASE_URL = "http://jwxt.qqhrit.com:20266/jwglxt";
  9. /**
  10. * 节次与周次合并去重函数
  11. * @param {Array<Object>} courses 原始解析课程数组
  12. * @returns {Array<Object>} 合并去重后的课程数组
  13. */
  14. function mergeAndDistinctCourses(courses) {
  15. if (!Array.isArray(courses) || courses.length <= 1) return courses;
  16. // 按天、课程名、教师、地点将课程分组,相同课程的记录归入同一组
  17. const grouped = {};
  18. courses.forEach(c => {
  19. const name = c.name || '';
  20. const teacher = c.teacher || '';
  21. const position = c.position || '';
  22. const day = c.day || 0;
  23. // 构造唯一标识,用于识别是否为同一门课
  24. const key = `${day}|${name}|${teacher}|${position}`;
  25. if (!grouped[key]) {
  26. grouped[key] = {
  27. name, teacher, position, day,
  28. // 记录每周对应的节次段,结构如 { 8: [ {start: 5, end: 6}, {start: 7, end: 8} ] }
  29. weeksMap: {}
  30. };
  31. }
  32. // 将该记录覆盖的所有周次拆开,分别记录对应周的节次段
  33. const weeksArr = Array.isArray(c.weeks) ? [...new Set(c.weeks)] : [];
  34. weeksArr.forEach(w => {
  35. if (!grouped[key].weeksMap[w]) grouped[key].weeksMap[w] = [];
  36. grouped[key].weeksMap[w].push({ start: c.startSection, end: c.endSection });
  37. });
  38. });
  39. const finalResult = [];
  40. // 逐组处理每门课程
  41. Object.values(grouped).forEach(group => {
  42. // 获取该课程有课的所有周次,升序排列
  43. const sortedWeeks = Object.keys(group.weeksMap).map(Number).sort((a, b) => a - b);
  44. // 记录每周合并后的最终节次段,结构如 { 8: [ {start: 5, end: 8} ] }
  45. const processedWeeksMap = {};
  46. sortedWeeks.forEach(w => {
  47. let parts = group.weeksMap[w];
  48. // 按开始节次和结束节次排序,确保合并顺序正确
  49. parts.sort((a, b) => a.start - b.start || a.end - b.end);
  50. // 合并该周内连续或重叠的节次段
  51. const mergedParts = [];
  52. let cur = parts[0];
  53. for (let i = 1; i < parts.length; i++) {
  54. // 如果当前节次段与下一段相邻或重叠,则延长当前段
  55. if (cur.end + 1 >= parts[i].start) {
  56. cur.end = Math.max(cur.end, parts[i].end);
  57. } else {
  58. mergedParts.push(cur);
  59. cur = parts[i];
  60. }
  61. }
  62. mergedParts.push(cur);
  63. processedWeeksMap[w] = mergedParts;
  64. });
  65. // 将节次结构完全相同的周次归为一组
  66. // 结构如 { "节次签名字符串": { sections: [...], weeks: [] } }
  67. const sectionsMap = {};
  68. sortedWeeks.forEach(w => {
  69. const parts = processedWeeksMap[w];
  70. // 将当前周的所有节次段组合成字符串签名,如 "1-2|5-6"
  71. const sig = parts.map(p => `${p.start}-${p.end}`).join('|');
  72. if (!sectionsMap[sig]) {
  73. sectionsMap[sig] = { sections: parts, weeks: [] };
  74. }
  75. sectionsMap[sig].weeks.push(w);
  76. });
  77. // 将每组相同节次结构的周次生成对应的课程对象
  78. Object.values(sectionsMap).forEach(secGroup => {
  79. secGroup.sections.forEach(sec => {
  80. finalResult.push({
  81. name: group.name,
  82. teacher: group.teacher,
  83. position: group.position,
  84. day: group.day,
  85. startSection: sec.start,
  86. endSection: sec.end,
  87. weeks: secGroup.weeks
  88. });
  89. });
  90. });
  91. });
  92. // 按星期、开始节次、课程名、起始周排序,保证输出结果顺序稳定
  93. finalResult.sort((a, b) => {
  94. return (a.day || 0) - (b.day || 0) ||
  95. (a.startSection || 0) - (b.startSection || 0) ||
  96. a.name.localeCompare(b.name) ||
  97. a.weeks[0] - b.weeks[0];
  98. });
  99. return finalResult;
  100. }
  101. /**
  102. * 解析周次字符串,处理单双周和周次范围
  103. * 兼容中英文括号、多分隔符、带"第"字前缀、带"共 N 周"尾巴等常见正方返回格式
  104. */
  105. function parseWeeks(weekStr) {
  106. if (!weekStr) return [];
  107. const cleaned = weekStr
  108. .replace(/周数[::]/g, '')
  109. .replace(/第/g, '')
  110. .replace(/周/g, '')
  111. .replace(/共\s*\d+\s*.*$/g, '');
  112. const segments = cleaned.split(/[,,、;;]/);
  113. const weeks = [];
  114. const segRegex = /(\d+)(?:\s*[-~]\s*(\d+))?\s*(?:[((]?\s*([单双])\s*周?\s*[))]?)?/g;
  115. for (const seg of segments) {
  116. const s = seg.trim();
  117. if (!s) continue;
  118. segRegex.lastIndex = 0;
  119. let m;
  120. while ((m = segRegex.exec(s)) !== null) {
  121. if (m[0] === '') {
  122. segRegex.lastIndex++;
  123. continue;
  124. }
  125. const start = parseInt(m[1], 10);
  126. const end = m[2] ? parseInt(m[2], 10) : start;
  127. const flagStr = m[3] || '';
  128. let flag = 0;
  129. if (flagStr.includes('单')) flag = 1;
  130. else if (flagStr.includes('双')) flag = 2;
  131. for (let w = start; w <= end; w++) {
  132. if (flag === 1 && w % 2 === 0) continue;
  133. if (flag === 2 && w % 2 !== 0) continue;
  134. weeks.push(w);
  135. }
  136. }
  137. }
  138. return [...new Set(weeks)].sort((a, b) => a - b);
  139. }
  140. /**
  141. * 独立处理课程时间逻辑的函数
  142. */
  143. function applyCustomTimeLogic(courses) {
  144. // 方案1:周末全天
  145. const TIME_SCHEME_1 = [
  146. { number: 1, startTime: "08:20", endTime: "09:05" },
  147. { number: 2, startTime: "09:05", endTime: "09:50" },
  148. { number: 3, startTime: "10:00", endTime: "10:45" },
  149. { number: 4, startTime: "10:45", endTime: "11:30" },
  150. { number: 5, startTime: "13:30", endTime: "14:15" },
  151. { number: 6, startTime: "14:15", endTime: "15:00" },
  152. { number: 7, startTime: "15:20", endTime: "16:05" },
  153. { number: 8, startTime: "16:05", endTime: "16:50" },
  154. { number: 9, startTime: "18:00", endTime: "18:45" },
  155. { number: 10, startTime: "18:45", endTime: "19:30" },
  156. { number: 11, startTime: "19:30", endTime: "20:15" }
  157. ];
  158. // 方案2:2#/3# 专用(3-4节)
  159. const TIME_SCHEME_2 = [
  160. { number: 3, startTime: "09:55", endTime: "10:40" },
  161. { number: 4, startTime: "10:50", endTime: "11:35" }
  162. ];
  163. // 方案3:图/馆/齐三机床 专用(3-4节)
  164. const TIME_SCHEME_3 = [
  165. { number: 3, startTime: "10:05", endTime: "10:50" },
  166. { number: 4, startTime: "11:00", endTime: "11:45" }
  167. ];
  168. return courses.map(course => {
  169. const is23Sharp = /(2#|3#)/.test(course.position);
  170. const isLibMachine = /(图|馆|齐三机床)/.test(course.position);
  171. const isWeekend = (course.day === 6 || course.day === 7);
  172. const isSpecialPos = (is23Sharp || isLibMachine);
  173. const isEndpoint3or4 = (course.startSection === 3 || course.startSection === 4 ||
  174. course.endSection === 3 || course.endSection === 4);
  175. const shouldApplyCustom = isWeekend || (isSpecialPos && isEndpoint3or4);
  176. if (shouldApplyCustom) {
  177. let startSlot, endSlot;
  178. if (isWeekend) {
  179. startSlot = TIME_SCHEME_1.find(s => s.number === course.startSection);
  180. endSlot = TIME_SCHEME_1.find(s => s.number === course.endSection);
  181. } else {
  182. // 工作日特殊教室逻辑
  183. const scheme = is23Sharp ? TIME_SCHEME_2 : TIME_SCHEME_3;
  184. const getSectionTime = (sec) => {
  185. if (sec === 3 || sec === 4) {
  186. return scheme.find(s => s.number === sec);
  187. }
  188. return TimeSlots.find(s => s.number === sec);
  189. };
  190. startSlot = getSectionTime(course.startSection);
  191. endSlot = getSectionTime(course.endSection);
  192. }
  193. if (startSlot && endSlot) {
  194. return {
  195. ...course,
  196. isCustomTime: true,
  197. customStartTime: startSlot.startTime,
  198. customEndTime: endSlot.endTime
  199. };
  200. }
  201. }
  202. return course;
  203. });
  204. }
  205. /**
  206. * 解析 API 返回的 JSON 数据
  207. */
  208. function parseJsonData(jsonData) {
  209. if (!jsonData || !Array.isArray(jsonData.kbList)) {
  210. return [];
  211. }
  212. const rawCourseList = jsonData.kbList;
  213. const initialCourseList = [];
  214. for (const rawCourse of rawCourseList) {
  215. if (!rawCourse.kcmc || !rawCourse.xm || !rawCourse.cdmc ||
  216. !rawCourse.xqj || !rawCourse.jcs || !rawCourse.zcd) {
  217. continue;
  218. }
  219. const weeksArray = parseWeeks(rawCourse.zcd);
  220. if (weeksArray.length === 0) {
  221. continue;
  222. }
  223. const sectionParts = rawCourse.jcs.split('-');
  224. const startSection = Number(sectionParts[0]);
  225. const endSection = Number(sectionParts[sectionParts.length - 1]);
  226. const day = Number(rawCourse.xqj);
  227. if (isNaN(day) || isNaN(startSection) || isNaN(endSection) ||
  228. day < 1 || day > 7 || startSection > endSection) {
  229. continue;
  230. }
  231. initialCourseList.push({
  232. name: rawCourse.kcmc.trim(),
  233. teacher: rawCourse.xm.trim(),
  234. position: rawCourse.cdmc.trim(),
  235. day: day,
  236. startSection: startSection,
  237. endSection: endSection,
  238. weeks: weeksArray
  239. });
  240. }
  241. const mergedCourses = mergeAndDistinctCourses(initialCourseList);
  242. return applyCustomTimeLogic(mergedCourses);
  243. }
  244. async function promptUserToStart() {
  245. return await window.shiguangBridgePromise.showAlert(
  246. "教务系统课表导入",
  247. "导入前请确保您已在浏览器中成功登录教务系统",
  248. "好的,开始导入"
  249. );
  250. }
  251. /**
  252. * 从教务系统获取学年学期选项
  253. * 学年:以选中项为中心,取前2年+后2年,共5个选项
  254. */
  255. async function fetchAcademicOptions() {
  256. const url = `${BASE_URL}/kbcx/xskbcx_cxXskbcxIndex.html?gnmkdm=N2151`;
  257. try {
  258. const response = await fetch(url, {
  259. method: "GET",
  260. credentials: "include"
  261. });
  262. if (!response.ok) return null;
  263. const htmlText = await response.text();
  264. const parser = new DOMParser();
  265. const doc = parser.parseFromString(htmlText, "text/html");
  266. const allYearOptions = Array.from(doc.querySelectorAll("#xnm option"))
  267. .filter(opt => opt.value !== "")
  268. .map(opt => ({
  269. value: opt.value,
  270. text: opt.textContent.trim(),
  271. selected: opt.selected
  272. }));
  273. const semesterOptions = Array.from(doc.querySelectorAll("#xqm option"))
  274. .filter(opt => opt.value !== "")
  275. .map(opt => ({
  276. value: opt.value,
  277. text: opt.textContent.trim(),
  278. selected: opt.selected
  279. }));
  280. if (allYearOptions.length === 0 || semesterOptions.length === 0) {
  281. return null;
  282. }
  283. const selectedIndex = allYearOptions.findIndex(opt => opt.selected);
  284. if (selectedIndex === -1) {
  285. return {
  286. yearOptions: allYearOptions.slice(0, 5),
  287. semesterOptions,
  288. defaultYearIndex: 0,
  289. defaultSemesterIndex: semesterOptions.findIndex(opt => opt.selected) !== -1
  290. ? semesterOptions.findIndex(opt => opt.selected)
  291. : 0
  292. };
  293. }
  294. const start = Math.max(0, selectedIndex - 2);
  295. const end = Math.min(allYearOptions.length, selectedIndex + 3);
  296. const yearOptions = allYearOptions.slice(start, end);
  297. const newDefaultIndex = selectedIndex - start;
  298. const defaultSemesterIndex = semesterOptions.findIndex(opt => opt.selected);
  299. return {
  300. yearOptions,
  301. semesterOptions,
  302. defaultYearIndex: newDefaultIndex,
  303. defaultSemesterIndex: defaultSemesterIndex !== -1 ? defaultSemesterIndex : 0
  304. };
  305. } catch (e) {
  306. return null;
  307. }
  308. }
  309. /**
  310. * 提示用户选择学年和学期
  311. */
  312. async function selectAcademicYearAndSemester() {
  313. const optionsData = await fetchAcademicOptions();
  314. if (!optionsData) {
  315. window.shiguangBridge.showToast("从教务系统读取学年学期失败,请确保登录状态。");
  316. return null;
  317. }
  318. const { yearOptions, semesterOptions, defaultYearIndex, defaultSemesterIndex } = optionsData;
  319. const yearTexts = yearOptions.map(item => item.text);
  320. const yearIndex = await window.shiguangBridgePromise.showSingleSelection(
  321. "选择学年",
  322. JSON.stringify(yearTexts),
  323. defaultYearIndex
  324. );
  325. if (yearIndex === null || yearIndex === -1) return null;
  326. const selectedYearCode = yearOptions[yearIndex].value;
  327. const semesterTexts = semesterOptions.map(item => item.text);
  328. const semesterIndex = await window.shiguangBridgePromise.showSingleSelection(
  329. "选择学期",
  330. JSON.stringify(semesterTexts),
  331. defaultSemesterIndex
  332. );
  333. if (semesterIndex === null || semesterIndex === -1) return null;
  334. const selectedSemesterCode = semesterOptions[semesterIndex].value;
  335. return {
  336. academicYear: selectedYearCode,
  337. semesterCode: selectedSemesterCode
  338. };
  339. }
  340. /**
  341. * 获取学期开学日期
  342. */
  343. async function fetchSemesterStartDate(academicYear, semesterCode) {
  344. const url = `${BASE_URL}/kbcx/xskbcxZccx_cxZcByXnxq.html?gnmkdm=N2154`;
  345. const requestBody = `xnm=${academicYear}&xqm=${semesterCode}`;
  346. try {
  347. const response = await fetch(url, {
  348. method: "POST",
  349. headers: {
  350. "accept": "application/json, text/javascript, */*; q=0.01",
  351. "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
  352. "x-requested-with": "XMLHttpRequest"
  353. },
  354. body: requestBody,
  355. credentials: "include"
  356. });
  357. if (response.ok) {
  358. const json = await response.json();
  359. if (Array.isArray(json) && json.length > 0) {
  360. // 优先找第1周,否则取第一项
  361. const firstWeekObj = json.find(item => String(item.zs) === "1" || String(item.zsmc) === "1") || json[0];
  362. if (firstWeekObj.rq) {
  363. const startDateStr = firstWeekObj.rq.split('/')[0];
  364. if (/^\d{4}-\d{2}-\d{2}$/.test(startDateStr)) {
  365. return startDateStr;
  366. }
  367. }
  368. if (firstWeekObj.zcrq) {
  369. const match = firstWeekObj.zcrq.match(/(\d{4}-\d{2}-\d{2})/);
  370. if (match) return match[1];
  371. }
  372. // 某些系统用 ksrq 字段
  373. if (firstWeekObj.ksrq) {
  374. const match = firstWeekObj.ksrq.match(/(\d{4}-\d{2}-\d{2})/);
  375. if (match) return match[1];
  376. }
  377. }
  378. }
  379. } catch (e) {
  380. // 获取失败不影响主流程
  381. }
  382. return null;
  383. }
  384. /**
  385. * 请求和解析课程数据
  386. */
  387. async function fetchAndParseCourses(academicYear, semesterCode) {
  388. const requestBody = `xnm=${academicYear}&xqm=${semesterCode}&kzlx=ck&xsdm=&kclbdm=`;
  389. const targetUrl = `${BASE_URL}/kbcx/xskbcx_cxXsgrkb.html?gnmkdm=N2151`;
  390. let courseResponse, semesterStartDate;
  391. try {
  392. // 并行获取课程数据和开学日期
  393. [courseResponse, semesterStartDate] = await Promise.all([
  394. fetch(targetUrl, {
  395. method: "POST",
  396. headers: {
  397. "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8"
  398. },
  399. body: requestBody,
  400. credentials: "include"
  401. }),
  402. fetchSemesterStartDate(academicYear, semesterCode)
  403. ]);
  404. } catch (e) {
  405. window.shiguangBridge.showToast("请求教务系统失败,请检查网络环境或登录状态。");
  406. return null;
  407. }
  408. try {
  409. if (courseResponse && courseResponse.ok) {
  410. const jsonText = await courseResponse.text();
  411. const jsonData = JSON.parse(jsonText);
  412. if (jsonData && jsonData.kbList) {
  413. const parsedCourses = parseJsonData(jsonData);
  414. if (parsedCourses.length > 0) {
  415. return {
  416. courses: parsedCourses,
  417. config: {
  418. semesterStartDate: semesterStartDate,
  419. semesterTotalWeeks: 20
  420. }
  421. };
  422. }
  423. }
  424. }
  425. } catch (e) {
  426. // 请求失败
  427. }
  428. window.shiguangBridge.showToast("未能获取课表数据,请检查网络环境或登录状态。");
  429. return null;
  430. }
  431. async function saveCourses(parsedCourses) {
  432. try {
  433. await window.shiguangBridgePromise.saveImportedCourses(JSON.stringify(parsedCourses));
  434. return true;
  435. } catch (error) {
  436. window.shiguangBridge.showToast(`课程保存失败: ${error.message}`);
  437. return false;
  438. }
  439. }
  440. const TimeSlots = [
  441. { number: 1, startTime: "08:00", endTime: "08:45" },
  442. { number: 2, startTime: "08:55", endTime: "09:40" },
  443. { number: 3, startTime: "10:20", endTime: "11:05" },
  444. { number: 4, startTime: "11:15", endTime: "12:00" },
  445. { number: 5, startTime: "13:30", endTime: "14:15" },
  446. { number: 6, startTime: "14:25", endTime: "15:10" },
  447. { number: 7, startTime: "15:20", endTime: "16:05" },
  448. { number: 8, startTime: "16:05", endTime: "16:50" },
  449. { number: 9, startTime: "18:00", endTime: "18:45" },
  450. { number: 10, startTime: "18:45", endTime: "19:30" },
  451. { number: 11, startTime: "19:30", endTime: "20:15" }
  452. ];
  453. async function importPresetTimeSlots(timeSlots) {
  454. if (timeSlots.length === 0) {
  455. window.shiguangBridge.showToast("警告:时间段为空,未导入时间段信息。");
  456. return;
  457. }
  458. try {
  459. await window.shiguangBridgePromise.savePresetTimeSlots(JSON.stringify(timeSlots));
  460. window.shiguangBridge.showToast("预设时间段导入成功!");
  461. } catch (error) {
  462. window.shiguangBridge.showToast("导入时间段失败: " + error.message);
  463. }
  464. }
  465. async function runImportFlow() {
  466. const alertConfirmed = await promptUserToStart();
  467. if (!alertConfirmed) {
  468. window.shiguangBridge.showToast("用户取消了导入。");
  469. return;
  470. }
  471. const selection = await selectAcademicYearAndSemester();
  472. if (!selection) {
  473. window.shiguangBridge.showToast("未选择学年学期,导入流程终止。");
  474. return;
  475. }
  476. const { academicYear, semesterCode } = selection;
  477. const result = await fetchAndParseCourses(academicYear, semesterCode);
  478. if (result === null) {
  479. return;
  480. }
  481. const { courses, config } = result;
  482. const saveResult = await saveCourses(courses);
  483. if (!saveResult) {
  484. return;
  485. }
  486. try {
  487. await window.shiguangBridgePromise.saveCourseConfig(JSON.stringify(config));
  488. let configMsg = "课表配置更新成功!";
  489. if (config.semesterStartDate) {
  490. configMsg += ` 开学日期:${config.semesterStartDate}`;
  491. }
  492. window.shiguangBridge.showToast(configMsg);
  493. } catch (error) {
  494. window.shiguangBridge.showToast(`课表配置保存失败: ${error.message}`);
  495. }
  496. await importPresetTimeSlots(TimeSlots);
  497. window.shiguangBridge.showToast(`课程导入成功,共导入 ${courses.length} 门课程!`);
  498. window.shiguangBridge.notifyTaskCompletion();
  499. }
  500. runImportFlow();