usc_01.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210
  1. // 南华大学(http://jwzx.usc.edu.cn:8924)拾光课程表适配脚本
  2. const BASE_URL = "http://jwzx.usc.edu.cn:8924";
  3. const [URL_COURSE, URL_CALENDAR] = [`${BASE_URL}/jsxsd/xskb/xskb_list.do`, `${BASE_URL}/jsxsd/jxzl/jxzl_query`];
  4. // 工具函数
  5. const parseSlots = arr => arr.map((s, i) => ({ number: i + 1, startTime: s.slice(0, 5), endTime: s.slice(6) }));
  6. const TIME_SLOTS_SUMMER = parseSlots(["08:00-08:45","08:55-09:40","10:00-10:45","10:55-11:40","15:00-15:45","15:55-16:40","17:00-17:45","17:55-18:40","20:00-20:45","20:55-21:40"]);
  7. const TIME_SLOTS_WINTER = parseSlots(["08:20-09:05","09:15-10:00","10:20-11:05","11:15-12:00","14:30-15:15","15:25-16:10","16:30-17:15","17:25-18:10","19:30-20:15","20:25-21:10"]);
  8. const pad2 = n => String(n).padStart(2, "0");
  9. const WEEK_MAP = { 一:1, 二:2, 三:3, 四:4, 五:5, 六:6, 日:7, 天:7 };
  10. const parseWeeks = str => [...new Set((str || "").split("(")[0].split(",").flatMap(s => {
  11. const [st, ed] = s.split("-").map(Number);
  12. return (st && ed) ? Array.from({length: ed - st + 1}, (_, i) => st + i) : [st].filter(n => !isNaN(n));
  13. }))].sort((a, b) => a - b);
  14. const parseSectionRange = text => {
  15. const [_, s, e] = String(text || "").match(/(\d+)\s*(?:-\s*(\d+))?/) || [];
  16. return s ? { start: +s, end: +(e || s) } : null;
  17. };
  18. function parseDayColumns(table) {
  19. for (const row of table.rows) {
  20. const days = [...row.children].filter(el => /^(TH|TD)$/.test(el.tagName))
  21. .map(el => WEEK_MAP[el.textContent.trim().match(/^星期([一二三四五六日天])$/)?.[1]] || null);
  22. if (days.filter(Boolean).length >= 5) return days.filter(d => d !== null);
  23. }
  24. return null;
  25. }
  26. function parseCellCourses(cell, selector, day, rowSec) {
  27. const block = cell.querySelector(selector);
  28. if (!block?.innerHTML.trim() || block.innerHTML.trim() === " ") return [];
  29. return block.innerHTML.split(/---------------------?/).flatMap(part => {
  30. if (!part.trim()) return [];
  31. const temp = document.createElement("div");
  32. temp.innerHTML = part;
  33. let name = "";
  34. for (const node of temp.childNodes) {
  35. if (node.tagName === "FONT") break;
  36. if (node.nodeType === 3) name += node.textContent.replace(/\s+/g, " ");
  37. }
  38. if (!(name = name.trim())) return [];
  39. const textOf = sel => (temp.querySelector(sel)?.textContent || "").trim();
  40. const teacher = textOf("font[title='老师'], font[title='教师']").replace("任课教师:", "") || "未知教师";
  41. const position = textOf("font[title='教室']") || "未知地点";
  42. const weekStr = textOf("font[title='周次(节次)']");
  43. const nums = (weekStr.match(/\[([\d\-,\s]+?)节\]/)?.[1] || "").split(/[^\d]+/).map(Number).filter(n => n > 0);
  44. const startSection = nums.length ? Math.min(...nums) : (rowSec?.start || 0);
  45. const endSection = nums.length ? Math.max(...nums) : (rowSec?.end || 0);
  46. const weeks = parseWeeks(weekStr);
  47. return (weeks.length && startSection) ? [{ name, teacher, position, day, startSection, endSection: Math.max(startSection, endSection), weeks }] : [];
  48. });
  49. }
  50. // 合并函数
  51. function mergeAndDistinctCourses(courses) {
  52. if (!courses?.length) return [];
  53. const key1 = c => [c.name, c.teacher, c.position, c.day, c.weeks.join()].join("|");
  54. const key2 = c => [c.name, c.teacher, c.position, c.day, c.startSection, c.endSection].join("|");
  55. return courses.map(c => ({...c, weeks: [...c.weeks].sort((a, b) => a - b)}))
  56. .sort((a, b) => key1(a).localeCompare(key1(b)) || a.startSection - b.startSection)
  57. .reduce((acc, n) => { // 阶段1:合并连续/重复节次
  58. const cur = acc[acc.length - 1];
  59. if (cur && key1(cur) === key1(n)) {
  60. if (cur.endSection + 1 === n.startSection) cur.endSection = n.endSection;
  61. else if (cur.startSection !== n.startSection || cur.endSection !== n.endSection) acc.push(n);
  62. } else acc.push(n);
  63. return acc;
  64. }, [])
  65. .sort((a, b) => key2(a).localeCompare(key2(b)))
  66. .reduce((acc, n) => { // 阶段2:合并相同节次的周次
  67. const cur = acc[acc.length - 1];
  68. if (cur && key2(cur) === key2(n)) cur.weeks = [...new Set([...cur.weeks, ...n.weeks])].sort((a,b) => a - b);
  69. else acc.push(n);
  70. return acc;
  71. }, []);
  72. }
  73. const parseTimetableToModel = doc => {
  74. const table = doc.getElementById("kbtable");
  75. if (!table) return [];
  76. const dayMap = parseDayColumns(table) || [1, 2, 3, 4, 5, 6, 7];
  77. const selector = table.querySelector("div.kbcontent") ? "div.kbcontent" : "div.kbcontent1";
  78. return mergeAndDistinctCourses([...table.rows].flatMap(row => {
  79. const cells = [...row.querySelectorAll("td")];
  80. const dayCells = cells.length === dayMap.length + 1 ? cells.slice(1) : cells;
  81. if (dayCells.length !== dayMap.length) return [];
  82. const rowSec = parseSectionRange(row.querySelector("th")?.textContent);
  83. return dayCells.flatMap((cell, i) => dayMap[i] ? parseCellCourses(cell, selector, dayMap[i], rowSec) : []);
  84. }));
  85. };
  86. // 网络请求与解析
  87. const parseHtml = html => new DOMParser().parseFromString(html, "text/html");
  88. const req = async (url, opts) => {
  89. const res = await fetch(url, { credentials: "include", ...opts });
  90. if (!res.ok) throw new Error(`HTTP ${res.status}`);
  91. return res.text();
  92. };
  93. const formOpts = body => ({ method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body });
  94. const requestCoursePage = semId => req(URL_COURSE, formOpts(`jx0404id=&cj0701id=&zc=&demo=&xnxq01id=${semId||""}`));
  95. const requestCoursePageByGet = semId => req(`${URL_COURSE}?xnxq01id=${encodeURIComponent(semId||"")}&zc=&cj0701id=&demo=`, { method: "GET" });
  96. const requestCalendarPage = semId => req(URL_CALENDAR, formOpts(`xnxq01id=${semId}`));
  97. function parseSemesterOptions(html) {
  98. const select = parseHtml(html).getElementById("xnxq01id");
  99. if (!select) return { list: [], defaultIndex: -1 };
  100. const all = [...select.options].map(o => ({ value: o.value, label: o.textContent.trim(), selected: o.selected })).filter(o => o.value);
  101. const idx = Math.max(0, all.findIndex(o => o.selected));
  102. const start = Math.max(0, idx - 3);
  103. return { list: all.slice(start, idx + 4), defaultIndex: idx - start };
  104. }
  105. async function fetchCourses(semId) {
  106. let html = await requestCoursePage(semId).catch(() => null);
  107. let courses = html ? parseTimetableToModel(parseHtml(html)) : [];
  108. if (!courses.length) {
  109. html = await requestCoursePageByGet(semId).catch(() => null);
  110. if (html) courses = parseTimetableToModel(parseHtml(html));
  111. }
  112. return courses;
  113. }
  114. async function fetchSemesterInfo(semId) {
  115. try {
  116. const doc = parseHtml(await requestCalendarPage(semId));
  117. const dates = [...doc.querySelectorAll("td[title]")].map(td => {
  118. const m = td.getAttribute("title")?.match(/(\d{4})年(\d{1,2})月(\d{1,2})日?/);
  119. return m ? `${m[1]}-${pad2(m[2])}-${pad2(m[3])}` : null;
  120. }).filter(Boolean).sort();
  121. const totalWeeks = Math.max(0, ...[...doc.querySelectorAll("#kbtable tr td:first-child")].map(td => parseInt(td.textContent, 10)).filter(n => !isNaN(n)));
  122. return { startDate: dates[0] || null, totalWeeks: totalWeeks || null };
  123. } catch { return { startDate: null, totalWeeks: null }; }
  124. }
  125. // 全局校验
  126. window.validateStartDateInput = input => !input?.trim() || /^\d{4}-\d{1,2}-\d{1,2}$/.test(input.trim()) ? false : "请输入形如 2026-09-01 的日期,或留空跳过";
  127. async function promptSemesterStartDate() {
  128. const input = await window.shiguangBridgePromise.showPrompt("设置开学日期", "未能自动获取开学日期...\n请输入本学期第一周的星期一日期(如 2026-09-01),留空则跳过:", "", "validateStartDateInput");
  129. const m = input?.trim().match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/);
  130. return m ? `${m[1]}-${pad2(m[2])}-${pad2(m[3])}` : null;
  131. }
  132. const computeTotalWeeks = courses => {
  133. const max = Math.max(0, ...courses.flatMap(c => c.weeks || []));
  134. return (max > 0 && max <= 40) ? max : 20;
  135. };
  136. function buildComboSchedule(semId, label) {
  137. const [_, y0, y1, term] = String(semId || "").match(/^(\d{4})-(\d{4})-(\d)$/) || [];
  138. if (!term) return null;
  139. const Y = term === "2" ? +y1 : +y0;
  140. const winter = { name: "秋冬季作息", startDate: `${term === "2" ? Y - 1 : Y}-10-01`, endDate: `${term === "2" ? Y : Y + 1}-04-30`, timeSlots: [] };
  141. return {
  142. name: `南华大学 ${label || semId} 作息方案`,
  143. publicSchedules: [ { name: "夏秋季作息", startDate: `${Y}-05-01`, endDate: `${Y}-09-30`, timeSlots: TIME_SLOTS_SUMMER }, winter ].sort((a,b) => a.startDate.localeCompare(b.startDate))
  144. };
  145. }
  146. const saveAppConfig = (startDate, weeks) => window.shiguangBridgePromise.saveCourseConfig(JSON.stringify({ semesterTotalWeeks: weeks || 20, firstDayOfWeek: 7, defaultClassDuration: 45, ...(startDate && { semesterStartDate: startDate }) }));
  147. const saveAppTimeSlots = () => window.shiguangBridgePromise.savePresetTimeSlots(JSON.stringify(TIME_SLOTS_WINTER));
  148. // 主流程
  149. (async function runImportFlow() {
  150. const bridge = window.shiguangBridge, pBridge = window.shiguangBridgePromise;
  151. try {
  152. if (!await pBridge.showAlert("提示", "请确保已登录教务在线,并已打开「学期理论课表」页面。\n是否开始导入?", "开始")) return;
  153. bridge.showToast("正在获取学期列表...");
  154. const { list, defaultIndex } = parseSemesterOptions(await requestCoursePage(""));
  155. if (!list.length) return bridge.showToast("未获取到学期列表,请检查登录状态。");
  156. const selIdx = await pBridge.showSingleSelection("选择学期", JSON.stringify(list.map(s => s.label)), defaultIndex);
  157. if (selIdx === null) return bridge.showToast("已取消导入。");
  158. bridge.showToast("正在请求课程数据...");
  159. const sem = list[selIdx], courses = await fetchCourses(sem.value);
  160. if (!courses.length) return bridge.showToast("未发现课程,请检查学期选择或登录状态。");
  161. bridge.showToast("正在获取学期信息...");
  162. const { startDate, totalWeeks } = await fetchSemesterInfo(sem.value);
  163. const finalStart = startDate || await promptSemesterStartDate();
  164. await saveAppConfig(finalStart, totalWeeks || computeTotalWeeks(courses));
  165. await pBridge.saveImportedCourses(JSON.stringify(courses));
  166. try {
  167. if (await saveAppTimeSlots()) {
  168. const combo = buildComboSchedule(sem.value, sem.label);
  169. if (combo) await pBridge.saveComboSchedule(JSON.stringify(combo));
  170. }
  171. } catch { bridge.showToast("时间段或组合作息导入异常,已跳过。"); }
  172. bridge.showToast(`成功导入 ${courses.length} 条课程`);
  173. bridge.notifyTaskCompletion();
  174. } catch (e) { bridge.showToast("异常: " + e.message); }
  175. })();