GDKM.js 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548
  1. //https://jw.gdkm.edu.cn/jsxsd/xskb/xskb_list.do
  2. // 广东科贸职业学院(gdkm.edu.cn) 拾光课程表适配脚本
  3. // 参考南昌航空大学科技学院(stcnchu.edu.cn) 拾光课程表适配脚本
  4. // 非该大学开发者适配,开发者无法及时发现问题
  5. // 出现问题请提联系开发者或者提交pr更改,这更加快速
  6. // 工具函数
  7. window.validateYearInput = function (input) {
  8. return /^[0-9]{4}$/.test(input) ? false : "请输入四位数字的学年喵~";
  9. };
  10. /**
  11. * 节次合并与去重
  12. */
  13. function mergeAndDistinctCourses(courses) {
  14. if (courses.length <= 1) return courses;
  15. courses.sort((a, b) => {
  16. return a.name.localeCompare(b.name) ||
  17. a.day - b.day ||
  18. a.startSection - b.startSection ||
  19. a.weeks.join(',').localeCompare(b.weeks.join(','));
  20. });
  21. const merged = [];
  22. let current = courses[0];
  23. for (let i = 1; i < courses.length; i++) {
  24. const next = courses[i];
  25. const isSameCourse =
  26. current.name === next.name &&
  27. current.teacher === next.teacher &&
  28. current.position === next.position &&
  29. current.day === next.day &&
  30. current.weeks.join(',') === next.weeks.join(',');
  31. const isContinuous = current.endSection + 1 === next.startSection;
  32. if (isSameCourse && isContinuous) {
  33. current.endSection = next.endSection;
  34. } else if (isSameCourse && current.startSection === next.startSection && current.endSection === next.endSection) {
  35. continue;
  36. } else {
  37. merged.push(current);
  38. current = next;
  39. }
  40. }
  41. merged.push(current);
  42. return merged;
  43. }
  44. // 核心解析逻辑
  45. function parseTimetableToModel(doc) {
  46. let timetable = doc.querySelector('table.qz-weeklyTable');
  47. if (!timetable) {
  48. const tables = Array.from(doc.querySelectorAll('table'));
  49. timetable = tables.sort((a, b) =>
  50. b.querySelectorAll('td').length - a.querySelectorAll('td').length
  51. )[0];
  52. }
  53. if (!timetable) return [];
  54. const rawCourses = [];
  55. const rows = Array.from(timetable.querySelectorAll('tbody tr'));
  56. // rowspan 占位表:key = 行下标,value = 该行被上方 rowspan 占用的列集合
  57. // 列号从 1 开始(1 = 周一)
  58. const occupied = {}; // { [rowIndex]: Set<col> }
  59. rows.forEach((row, rowIndex) => {
  60. const timeTd = row.querySelector('td[name="timeTd"]');
  61. const sectionText = timeTd ? timeTd.innerText : '';
  62. const rowSection = parseSectionFromLabel(sectionText);
  63. const courseTds = Array.from(row.querySelectorAll('td[name="kbDataTd"]'));
  64. // 本行被占用的列
  65. const occupiedCols = occupied[rowIndex] || new Set();
  66. let col = 1; // 从第 1 列(周一)开始
  67. let tdIndex = 0; // courseTds 的下标
  68. while (tdIndex < courseTds.length) {
  69. // 跳过被上方 rowspan 占用的列
  70. while (occupiedCols.has(col)) col++;
  71. const td = courseTds[tdIndex];
  72. const day = col; // 这个 td 真正对应的星期几
  73. // 处理这个 td 里的课程
  74. const items = td.querySelectorAll('li.courselists-item');
  75. items.forEach(li => {
  76. const name = li.querySelector('.qz-hasCourse-title')?.innerText.trim() || '';
  77. if (!name) return;
  78. const abbr = li.querySelector('.qz-hasCourse-abbrinfo')?.innerText || '';
  79. const teacher = extractField(abbr, '老师') || '未知教师';
  80. const position = extractField(abbr, '地点') || '未知地点';
  81. const weekStr = extractField(abbr, '时间') || '';
  82. const parsed = parseSectionFromWeekStr(weekStr);
  83. const finalStart = parsed.startSection || rowSection.startSection;
  84. const finalEnd = parsed.endSection || rowSection.endSection;
  85. if (finalStart > 0) {
  86. rawCourses.push({
  87. name,
  88. teacher,
  89. weeks: parseWeeks(weekStr),
  90. position,
  91. day,
  92. startSection: finalStart,
  93. endSection: finalEnd
  94. });
  95. }
  96. });
  97. // 看这个 td 有没有 rowspan
  98. const rowspan = parseInt(td.getAttribute('rowspan') || '1', 10);
  99. if (rowspan > 1) {
  100. // 它占用的列:接下来的 rowspan-1 行,这一列都不出现 td
  101. for (let r = rowIndex + 1; r < rowIndex + rowspan; r++) {
  102. if (!occupied[r]) occupied[r] = new Set();
  103. occupied[r].add(col);
  104. }
  105. }
  106. col++;
  107. tdIndex++;
  108. }
  109. });
  110. return mergeAndDistinctCourses(rawCourses);
  111. }
  112. //工具
  113. // 从 "老师:张三;时间:3周[1-6节];地点:xxx" 里取某个字段
  114. function extractField(text, key) {
  115. if (!text) return '';
  116. // 1. 找到 "老师:" 或 "老师:" 的位置
  117. let startIdx = -1;
  118. const colonVariants = [key + ':', key + ':'];
  119. for (let i = 0; i < colonVariants.length; i++) {
  120. startIdx = text.indexOf(colonVariants[i]);
  121. if (startIdx !== -1) {
  122. startIdx += colonVariants[i].length; // 跳过 key 和冒号
  123. break;
  124. }
  125. }
  126. if (startIdx === -1) return '';
  127. // 2. 从 startIdx 往后找第一个 ; 或 ;或换行
  128. let endIdx = text.length;
  129. for (let i = startIdx; i < text.length; i++) {
  130. const ch = text[i];
  131. if (ch === ';' || ch === ';' || ch === '\n') {
  132. endIdx = i;
  133. break;
  134. }
  135. }
  136. return text.substring(startIdx, endIdx).trim();
  137. }
  138. // 从 "第一大节 (01、02小节)" 里取节次
  139. function parseSectionFromLabel(text) {
  140. let startSection = 0, endSection = 0;
  141. if (!text) return { startSection, endSection };
  142. // 找 "(" 和 "小节"
  143. const openIdx = Math.max(text.indexOf('('), text.indexOf('('));
  144. const closeIdx = text.indexOf('小节', openIdx);
  145. if (openIdx === -1 || closeIdx === -1) return { startSection, endSection };
  146. const inside = text.substring(openIdx + 1, closeIdx); // 例如 "01、02"
  147. // 用 、 或 , 切开
  148. const parts = inside.split(/[、,,]/);
  149. const nums = [];
  150. parts.forEach(p => {
  151. const n = parseInt(p.trim(), 10);
  152. if (!isNaN(n)) nums.push(n);
  153. });
  154. if (nums.length > 0) {
  155. startSection = nums[0];
  156. endSection = nums[nums.length - 1];
  157. }
  158. return { startSection, endSection };
  159. }
  160. // 从 "3周[1-6节]" 里取节次
  161. function parseSectionFromWeekStr(weekStr) {
  162. let startSection = 0, endSection = 0;
  163. if (!weekStr) return { startSection, endSection };
  164. // 找 [ 和 节]
  165. const openIdx = weekStr.indexOf('[');
  166. const closeIdx = weekStr.indexOf('节]', openIdx);
  167. if (openIdx === -1 || closeIdx === -1) return { startSection, endSection };
  168. const inside = weekStr.substring(openIdx + 1, closeIdx); // 例如 "1-6" 或 "5-8"
  169. const dashIdx = inside.indexOf('-');
  170. if (dashIdx === -1) {
  171. // 只有一个数字
  172. const n = parseInt(inside.trim(), 10);
  173. if (!isNaN(n)) {
  174. startSection = n;
  175. endSection = n;
  176. }
  177. } else {
  178. const a = parseInt(inside.substring(0, dashIdx).trim(), 10);
  179. const b = parseInt(inside.substring(dashIdx + 1).trim(), 10);
  180. if (!isNaN(a)) startSection = a;
  181. if (!isNaN(b)) endSection = b;
  182. }
  183. return { startSection, endSection };
  184. }
  185. // 从 "3-4,9-18周" 里解析出所有周次
  186. // 返回 [3,4,9,10,11,...,18] 这样的数组
  187. function parseWeeks(weekStr) {
  188. if (!weekStr) return [];
  189. // 1. 去掉 "周" 之后的内容,比如 "3-4,9-18周[1-2节]" -> "3-4,9-18"
  190. let endIdx = weekStr.indexOf('周');
  191. let core = endIdx === -1 ? weekStr : weekStr.substring(0, endIdx);
  192. // 2. 去掉 "[" 之前可能残留的东西(保险)
  193. const bracketIdx = core.indexOf('[');
  194. if (bracketIdx !== -1) core = core.substring(0, bracketIdx);
  195. const weeks = [];
  196. // 3. 按逗号切开,每段可能是 "3" 或 "9-18"
  197. const segments = core.split(/[,,]/);
  198. segments.forEach(seg => {
  199. seg = seg.trim();
  200. if (!seg) return;
  201. const dashIdx = seg.indexOf('-');
  202. if (dashIdx === -1) {
  203. // 单个周
  204. const n = parseInt(seg, 10);
  205. if (!isNaN(n)) weeks.push(n);
  206. } else {
  207. // 范围周
  208. const a = parseInt(seg.substring(0, dashIdx).trim(), 10);
  209. const b = parseInt(seg.substring(dashIdx + 1).trim(), 10);
  210. if (!isNaN(a) && !isNaN(b)) {
  211. for (let i = a; i <= b; i++) weeks.push(i);
  212. }
  213. }
  214. });
  215. // 4. 去重 + 排序
  216. const unique = [];
  217. weeks.forEach(w => {
  218. if (unique.indexOf(w) === -1) unique.push(w);
  219. });
  220. unique.sort((a, b) => a - b);
  221. return unique;
  222. }
  223. // 配置与流程
  224. async function saveAppConfig(semesterStartDate, semesterTotalWeeks) {
  225. const config = { "semesterTotalWeeks": semesterTotalWeeks, "firstDayOfWeek": 1, "semesterStartDate": semesterStartDate };
  226. return await window.shiguangBridgePromise.saveCourseConfig(JSON.stringify(config));
  227. }
  228. /**
  229. * 返回作息
  230. */
  231. async function saveAppTimeSlots() {
  232. const slots = [
  233. { "number": 1, "startTime": "08:30", "endTime": "09:10" },
  234. { "number": 2, "startTime": "09:20", "endTime": "10:00" },
  235. { "number": 3, "startTime": "10:20", "endTime": "11:00" },
  236. { "number": 4, "startTime": "11:10", "endTime": "11:50" },
  237. { "number": 5, "startTime": "14:00", "endTime": "14:40" },
  238. { "number": 6, "startTime": "14:50", "endTime": "15:20" },
  239. { "number": 7, "startTime": "15:30", "endTime": "16:10" },
  240. { "number": 8, "startTime": "16:20", "endTime": "16:50" },
  241. { "number": 9, "startTime": "18:30", "endTime": "19:10" },
  242. { "number": 10, "startTime": "19:20", "endTime": "19:50" },
  243. { "number": 11, "startTime": "20:00", "endTime": "20:40" },
  244. { "number": 12, "startTime": "20:50", "endTime": "21:20" },
  245. ]
  246. return await window.shiguangBridgePromise.savePresetTimeSlots(JSON.stringify(slots));
  247. }
  248. //拿开学日期
  249. function getSchoolStartDate(htmlText) {
  250. const parser = new DOMParser();
  251. const doc = parser.parseFromString(htmlText, 'text/html');
  252. // 找到第1周那一行
  253. const rows = doc.querySelectorAll('tr.qz-weeklyTable-tr');
  254. let firstWeekRow = null;
  255. for (const row of rows) {
  256. const label = row.querySelector('.qz-weeklyTable-label .td-cell');
  257. if (label && label.textContent.trim() === '第1周') {
  258. firstWeekRow = row;
  259. break;
  260. }
  261. }
  262. if (!firstWeekRow) throw new Error('未找到第1周的数据');
  263. // 取日期单元格:cells[0]=星期一,cells[5]=星期六
  264. const cells = firstWeekRow.querySelectorAll('td.qz-weeklyTable-normalDay');
  265. const mondayText = cells[0].textContent.trim(); // 例如 "31"
  266. const saturdayText = cells[5].textContent.trim(); // 例如 "09月05日"
  267. // 从星期六的文本里提取月份和日号
  268. const satMatch = saturdayText.match(/(\d{1,2})月(\d{1,2})日/);
  269. if (!satMatch) throw new Error('无法从周末日期中解析出月日');
  270. const month = parseInt(satMatch[1], 10);
  271. const satDay = parseInt(satMatch[2], 10);
  272. // 星期一那格可能只有日号,也可能带“月日”
  273. let day;
  274. const dayMatch = mondayText.match(/(\d{1,2})日/);
  275. day = dayMatch ? parseInt(dayMatch[1], 10) : parseInt(mondayText, 10);
  276. if (isNaN(day)) throw new Error('无法解析星期一的日期');
  277. // 取学年起始年(如 "2026-2027-1" → 2026)
  278. let year = new Date().getFullYear();
  279. const yearSelect = doc.querySelector('#xnxq01id');
  280. if (yearSelect) {
  281. const selected = yearSelect.querySelector('option[selected]');
  282. const value = selected ? selected.value : yearSelect.options[0].value;
  283. year = parseInt(value.split('-')[0], 10);
  284. }
  285. // 跨月修正:若周一日期数字 > 周六日期数字,说明周一在上一个月
  286. let finalMonth = month;
  287. let finalDay = day;
  288. if (day > satDay) {
  289. finalMonth = month - 1;
  290. if (finalMonth === 0) finalMonth = 12;
  291. }
  292. // 格式化为 YYYY-MM-DD
  293. const mm = String(finalMonth).padStart(2, '0');
  294. const dd = String(finalDay).padStart(2, '0');
  295. return `${year}-${mm}-${dd}`;
  296. }
  297. //拿总周数
  298. function getTotalWeeks(htmlText) {
  299. if (!htmlText || typeof htmlText !== 'string') return null;
  300. const parser = new DOMParser();
  301. const doc = parser.parseFromString(htmlText, 'text/html');
  302. // 优先用 .qz-weeklyTable-label 精确匹配
  303. const labels = doc.querySelectorAll('.qz-weeklyTable-label .td-cell');
  304. let maxWeek = 0;
  305. labels.forEach(el => {
  306. const text = el.textContent.trim();
  307. const match = text.match(/^第\s*(\d+)\s*周$/);
  308. if (match) {
  309. const n = parseInt(match[1], 10);
  310. if (n > maxWeek) maxWeek = n;
  311. }
  312. });
  313. // 如果精确选择器没找到,退回全表扫描
  314. if (maxWeek === 0) {
  315. const allText = doc.body ? doc.body.innerText : '';
  316. const re = /第\s*(\d+)\s*周/g;
  317. let m;
  318. while ((m = re.exec(allText)) !== null) {
  319. const n = parseInt(m[1], 10);
  320. if (n > maxWeek) maxWeek = n;
  321. }
  322. }
  323. // 解析失败返回 null,成功返回最大周数
  324. return maxWeek > 0 ? maxWeek : null;
  325. }
  326. // 拿学年
  327. function getAcademicYearInfo(htmlText) {
  328. const parser = new DOMParser();
  329. const doc = parser.parseFromString(htmlText, 'text/html');
  330. const results = [];
  331. // 从下拉框取所有选项
  332. const select = doc.querySelector('#xnxq01id');
  333. if (select) {
  334. const options = select.querySelectorAll('option');
  335. options.forEach(opt => {
  336. const value = (opt.value || '').trim(); // 例如 "2026-2027-1"
  337. if (!value) return;
  338. // 只做格式校验:起始年-结束年-学期
  339. if (/^\d{4}-\d{4}-\d+$/.test(value)) {
  340. results.push(value);
  341. }
  342. });
  343. }
  344. return results.length > 0 ? results : null;
  345. }
  346. window.isYMD = function (str) {
  347. // 1. 格式匹配:4位-2位-2位
  348. if (!/^\d{4}-\d{2}-\d{2}$/.test(str)) return "开始日期格式错误了喵~。请再次输入喵~";
  349. // 2. 校验是否是真实存在的日期(防止 2024-02-30)
  350. const [y, m, d] = str.split('-').map(Number);
  351. const date = new Date(y, m - 1, d);
  352. if (!(date.getFullYear() === y && date.getMonth() === m - 1 && date.getDate() === d)) {
  353. return "开始日期格式错误了喵~。请再次输入喵~"
  354. }
  355. return false
  356. }
  357. window.isAcademicYear = function (str) {
  358. if (typeof str !== 'string') {
  359. return "请输入学年学期喵~(例如 2025-2026-1)";
  360. }
  361. // 1. 格式匹配:4位-4位-1到2位
  362. if (!/^\d{4}-\d{4}-\d{1,2}$/.test(str.trim())) {
  363. return "学年学期格式错误喵~。正确格式为:2025-2026-1";
  364. }
  365. const parts = str.trim().split('-');
  366. const startYear = parseInt(parts[0], 10);
  367. const endYear = parseInt(parts[1], 10);
  368. const semester = parseInt(parts[2], 10);
  369. // 2. 结束学年必须比起始学年大 1
  370. if (endYear !== startYear + 1) {
  371. return "结束学年必须是起始学年的下一年喵~(例如 2025-2026)";
  372. }
  373. // 3. 学期只能是 1 或 2(如学校有小学期可自行放宽)
  374. if (semester !== 1 && semester !== 2) {
  375. return "学期只能是 1 或 2 喵~";
  376. }
  377. return false;
  378. };
  379. // ================= 流程编排 =================
  380. window.semesterTotalWeeksInput = function (str) {
  381. return /^-?\d+$/.test(str) ? false : "请输入周数喵~只要数字喵~"
  382. }
  383. async function runImportFlow() {
  384. try {
  385. const confirmed = await window.shiguangBridgePromise.showAlert("提示喵~", "请确保已成功登录教务系统喵~。是否开始导入?", "开始");
  386. if (!confirmed) return;
  387. const response_calendar = await fetch("https://jw.gdkm.edu.cn/jsxsd/jxzl/jxzl_query", {
  388. "headers": {
  389. "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
  390. "accept-language": "zh-CN,zh;q=0.9",
  391. "priority": "u=0, i",
  392. "upgrade-insecure-requests": "1"
  393. },
  394. "body": null,
  395. "method": "GET",
  396. "mode": "cors",
  397. "credentials": "include"
  398. })
  399. const html_calendar = await response_calendar.text();
  400. /*
  401. // 1. 获取就读校区
  402. const campusIndex = await window.shiguangBridgePromise.showSingleSelection("选择所在校区喵~", JSON.stringify(["清远校区", "广州白云校区","广州天河校区"]), -1);
  403. if (campusIndex === null) return;
  404. */
  405. // 2. 获取学年
  406. var semesterId_array = getAcademicYearInfo(html_calendar)
  407. var semesterId_index = await window.shiguangBridgePromise.showSingleSelection("选择学年学期喵~", JSON.stringify(semesterId_array), 0);
  408. var semesterId = semesterId_array[semesterId_index]
  409. if (!semesterId) return;
  410. var semesterStartDate = getSchoolStartDate(html_calendar);
  411. if (window.isYMD(semesterStartDate)) {
  412. semesterStartDate = await window.shiguangBridgePromise.showPrompt("选择开始日期喵~" + "errror " + semesterStartDate + window.isYMD(semesterStartDate), "请输入要导入课程的开始日期喵~ 格式为:YYYY-MM-DD,例如1145-01-04", "", "isYMD");
  413. }
  414. if (!semesterStartDate) return;
  415. var semesterTotalWeeks = getTotalWeeks(html_calendar)
  416. if (window.semesterTotalWeeksInput(semesterTotalWeeks)) {
  417. semesterTotalWeeks = await window.shiguangBridgePromise.showPrompt("选择周数喵~" + "errror " + semesterTotalWeeks + window.semesterTotalWeeksInput(semesterStartDate), "请输入要导入课程的周数喵~(例如 20):", "", "semesterTotalWeeksInput");
  418. }
  419. if (!semesterTotalWeeks) return;
  420. window.shiguangBridge.showToast("正在请求数据喵~");
  421. const url = `https://jw.gdkm.edu.cn/jsxsd/xskb/xskb_list.do?viweType=0&xnxq01id=${semesterId}&zc=`;
  422. const response = await fetch(url, {
  423. method: "GET",
  424. credentials: "include",
  425. headers: {
  426. "Referer": "https://jw.gdkm.edu.cn/jsxsd/"
  427. }
  428. });
  429. const html = await response.text();
  430. const finalCourses = parseTimetableToModel(new DOMParser().parseFromString(html, "text/html"));
  431. if (finalCourses.length === 0) {
  432. window.shiguangBridge.showToast("未发现课程,请检查学期选择或登录状态喵~");
  433. return;
  434. }
  435. // 保存全局设置
  436. await saveAppConfig(semesterStartDate, parseInt(semesterTotalWeeks));
  437. // 传入作息
  438. await saveAppTimeSlots();
  439. // 保存课程
  440. await window.shiguangBridgePromise.saveImportedCourses(JSON.stringify(finalCourses));
  441. window.shiguangBridge.showToast(`成功导入 ${finalCourses.length} 门课程喵~`);
  442. window.shiguangBridge.notifyTaskCompletion();
  443. } catch (error) {
  444. window.shiguangBridge.showToast("异常喵~ " + error.message);
  445. }
  446. }
  447. // 启动
  448. runImportFlow();