ustc.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368
  1. // 中国科学技术大学(ustc.edu.cn) 拾光课程表适配脚本
  2. // ========== 辅助函数 ==========
  3. /**
  4. * 解析 USTC 周次字符串
  5. * 支持格式: "1-16周", "1-8,10-16周", "1-15(单)周", "2-16(双)周", "1-5,9-11(单),12-15周"
  6. * @param {string} str - 周次字符串
  7. * @returns {number[]} - 周次数组,如 [1,2,3,4,5,9,11,12,13,14,15]
  8. */
  9. function parseWeekString(str) {
  10. if (!str) return [];
  11. const parts = str.replace(/[^0-9,()单双\-]/g, '').split(',');
  12. const result = [];
  13. for (const part of parts) {
  14. if (!part.trim()) continue;
  15. // 匹配 "5-10(单)" 或 "6-12(双)" 格式
  16. const oddEvenMatch = part.match(/(\d+)-(\d+)\((单|双)\)/);
  17. if (oddEvenMatch) {
  18. const start = parseInt(oddEvenMatch[1]);
  19. const end = parseInt(oddEvenMatch[2]);
  20. const flag = oddEvenMatch[3];
  21. for (let i = start; i <= end; i++) {
  22. if (flag === '单' && i % 2 === 0) continue;
  23. if (flag === '双' && i % 2 !== 0) continue;
  24. result.push(i);
  25. }
  26. } else if (part.includes('-')) {
  27. // 匹配 "1-8" 格式
  28. const cleaned = part.replace(/[()]/g, '');
  29. const [start, end] = cleaned.split('-').map(Number);
  30. if (!isNaN(start) && !isNaN(end)) {
  31. for (let i = start; i <= end; i++) result.push(i);
  32. }
  33. } else {
  34. // 匹配单个数字 "3"
  35. const num = parseInt(part);
  36. if (!isNaN(num)) result.push(num);
  37. }
  38. }
  39. return [...new Set(result)].sort((a, b) => a - b);
  40. }
  41. /**
  42. * 解析节次字符串(复用 getWeekNumber 逻辑,支持范围格式)
  43. * @param {string} str - 如 "3,4,5" 或 "3-5"
  44. * @returns {number[]} - 如 [3, 4, 5]
  45. */
  46. function parseTimeSpan(str) {
  47. if (!str) return [];
  48. const cleaned = str.replace(/[^0-9,-]/g, '');
  49. const parts = cleaned.split(',');
  50. const result = [];
  51. for (const part of parts) {
  52. if (!part.trim()) continue;
  53. if (part.includes('-')) {
  54. const [start, end] = part.split('-').map(Number);
  55. if (!isNaN(start) && !isNaN(end)) {
  56. for (let i = start; i <= end; i++) result.push(i);
  57. }
  58. } else {
  59. const num = Number(part);
  60. if (!isNaN(num)) result.push(num);
  61. }
  62. }
  63. return [...new Set(result)].sort((a, b) => a - b);
  64. }
  65. /**
  66. * 合并同课程不同周的数据
  67. * 同一门课在不同周、相同时间段的条目合并 weeks 数组
  68. * @param {Array} courses - 原始课程数组
  69. * @returns {Array} - 合并后的课程数组
  70. */
  71. function mergeCourses(courses) {
  72. const groups = {};
  73. courses.forEach(c => {
  74. const key = `${c.name}|${c.teacher}|${c.position}|${c.day}|${c.startSection}|${c.endSection}`;
  75. if (!groups[key]) {
  76. groups[key] = { name: c.name, teacher: c.teacher, position: c.position, day: c.day, startSection: c.startSection, endSection: c.endSection, weeks: [] };
  77. }
  78. c.weeks.forEach(w => {
  79. if (!groups[key].weeks.includes(w)) groups[key].weeks.push(w);
  80. });
  81. });
  82. return Object.values(groups).sort((a, b) =>
  83. a.day !== b.day ? a.day - b.day : a.startSection - b.startSection
  84. );
  85. }
  86. // ========== DOM 解析 ==========
  87. /**
  88. * 获取课表所在的 document 对象
  89. * USTC 教务系统将课表加载在 iframe 中(name="e-home-iframe-1")
  90. * 需要先尝试直接访问,再尝试从 iframe 中获取
  91. * @returns {Document|null} - 课表所在的 document,未找到返回 null
  92. */
  93. function getTimetableDocument() {
  94. // 策略 1:课表表格直接在当前页面(如脚本运行在 iframe 内部)
  95. var table = document.evaluate(
  96. "/html/body/div[2]/div[3]/div/div/table",
  97. document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null
  98. ).singleNodeValue;
  99. if (table) return document;
  100. // 策略 2:课表在 iframe 中(e-home-iframe-1 或任意 iframe)
  101. var iframes = document.querySelectorAll('iframe');
  102. for (var i = 0; i < iframes.length; i++) {
  103. try {
  104. var iframeDoc = iframes[i].contentDocument || iframes[i].contentWindow.document;
  105. if (!iframeDoc) continue;
  106. var t = iframeDoc.evaluate(
  107. "/html/body/div[2]/div[3]/div/div/table",
  108. iframeDoc, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null
  109. ).singleNodeValue;
  110. if (t) return iframeDoc;
  111. } catch (e) {
  112. // 跨域 iframe 无法访问,跳过
  113. }
  114. }
  115. // 策略 3:尝试更通用的选择器(适配页面结构变化)
  116. // 在当前页面和 iframe 中查找包含课程元素的 table
  117. var allDocs = [document];
  118. for (var j = 0; j < iframes.length; j++) {
  119. try {
  120. var doc = iframes[j].contentDocument || iframes[j].contentWindow.document;
  121. if (doc) allDocs.push(doc);
  122. } catch (e) { /* skip cross-origin */ }
  123. }
  124. for (var k = 0; k < allDocs.length; k++) {
  125. var courseElements = allDocs[k].querySelectorAll('.c');
  126. if (courseElements.length > 0) return allDocs[k];
  127. }
  128. return null;
  129. }
  130. /**
  131. * 从指定 document 中解析所有课程数据
  132. *
  133. * 课程元素(.c class)内部结构:
  134. * - .title: 课程名称
  135. * - .teacher .name: 教师姓名(可多个)
  136. * - .time .week: 上课周次
  137. * - .timespan: 上课节次
  138. * - .classroom .name: 上课教室
  139. *
  140. * @param {Document} doc - 包含课表的 document 对象
  141. * @returns {Array} - 拾光课表格式的课程数组
  142. */
  143. function parseCoursesFromDoc(doc) {
  144. var timeTable = doc.evaluate(
  145. "/html/body/div[2]/div[3]/div/div/table",
  146. doc, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null
  147. ).singleNodeValue;
  148. if (!timeTable) return [];
  149. var rows = timeTable.getElementsByTagName("tr");
  150. var allCourses = [];
  151. for (var dayNumber = 0; dayNumber < 7; dayNumber++) {
  152. for (var i = 0; i < rows.length; i++) {
  153. // 带有 class 属性的 <tr> 是包含课程数据的行
  154. if (!rows[i].hasAttribute("class")) continue;
  155. var cells = rows[i].getElementsByTagName("td");
  156. var count = 0;
  157. var dayIdx = dayNumber;
  158. // 找到第 dayNumber 个不带 style 的 <td>(即对应星期几的列)
  159. var target = Array.from(cells).find(function(elem) {
  160. if (!elem.hasAttribute("style")) return ++count > dayIdx;
  161. return false;
  162. });
  163. if (!target) continue;
  164. // 提取该单元格中所有课程元素
  165. var elements = target.querySelectorAll(".c");
  166. elements.forEach(function(el) {
  167. var name = el.querySelector(".title")?.innerText?.trim();
  168. var teacher = Array.from(el.querySelectorAll(".teacher .name"))
  169. .map(function(e) { return e.innerText.trim(); }).join(", ");
  170. var weekStr = el.querySelector(".time .week")?.innerText?.trim();
  171. var timeSpanStr = el.querySelector(".timespan")?.innerText?.trim();
  172. var location = el.querySelector(".classroom .name")?.innerText?.trim();
  173. if (!name || !weekStr || !timeSpanStr) return;
  174. var weeks = parseWeekString(weekStr);
  175. var timeSpan = parseTimeSpan(timeSpanStr);
  176. if (weeks.length === 0 || timeSpan.length === 0) return;
  177. allCourses.push({
  178. name: name,
  179. teacher: teacher || "未知教师",
  180. position: location || "",
  181. day: dayNumber + 1, // 0=周一 → 1
  182. startSection: Math.min.apply(null, timeSpan),
  183. endSection: Math.max.apply(null, timeSpan),
  184. weeks: weeks
  185. });
  186. });
  187. }
  188. }
  189. return mergeCourses(allCourses);
  190. }
  191. /**
  192. * 从页面提取学期名称
  193. * 在当前页面和 iframe 中查找学期选择器
  194. * @returns {string} - 学期名称
  195. */
  196. function getSemesterName() {
  197. // 尝试在当前页面查找
  198. var el = document.querySelector(
  199. '.selectize-input.items.full.has-options.has-items .item'
  200. );
  201. if (el) return el.textContent.trim();
  202. // 尝试在 iframe 中查找
  203. var iframes = document.querySelectorAll('iframe');
  204. for (var i = 0; i < iframes.length; i++) {
  205. try {
  206. var iframeDoc = iframes[i].contentDocument || iframes[i].contentWindow.document;
  207. if (!iframeDoc) continue;
  208. var iframeEl = iframeDoc.querySelector(
  209. '.selectize-input.items.full.has-options.has-items .item'
  210. );
  211. if (iframeEl) return iframeEl.textContent.trim();
  212. } catch (e) { /* skip cross-origin */ }
  213. }
  214. // 尝试更通用的选择器
  215. var semesterSpan = document.querySelector('.currentSemester');
  216. if (semesterSpan) return semesterSpan.textContent.trim().replace(/,$/, '');
  217. return '未知学期';
  218. }
  219. // ========== USTC 时间段 ==========
  220. /**
  221. * 获取 USTC 课程时间段配置
  222. * @returns {Array} - TimeSlotJsonModel 数组
  223. */
  224. function getUSTCTimeSlots() {
  225. return [
  226. { number: 1, startTime: "07:50", endTime: "08:35" },
  227. { number: 2, startTime: "08:40", endTime: "09:25" },
  228. { number: 3, startTime: "09:45", endTime: "10:30" },
  229. { number: 4, startTime: "10:35", endTime: "11:20" },
  230. { number: 5, startTime: "11:25", endTime: "12:10" },
  231. { number: 6, startTime: "14:00", endTime: "14:45" },
  232. { number: 7, startTime: "14:50", endTime: "15:35" },
  233. { number: 8, startTime: "15:55", endTime: "16:40" },
  234. { number: 9, startTime: "16:45", endTime: "17:30" },
  235. { number: 10, startTime: "17:35", endTime: "18:20" },
  236. { number: 11, startTime: "19:30", endTime: "20:15" },
  237. { number: 12, startTime: "20:20", endTime: "21:05" },
  238. { number: 13, startTime: "21:10", endTime: "21:55" }
  239. ];
  240. }
  241. // ========== 轮询等待 DOM 加载 ==========
  242. /**
  243. * 延迟指定毫秒
  244. * @param {number} ms - 毫秒数
  245. * @returns {Promise}
  246. */
  247. function delay(ms) {
  248. return new Promise(resolve => setTimeout(resolve, ms));
  249. }
  250. /**
  251. * 轮询等待课表 DOM 加载完成
  252. * 尝试在当前页面和 iframe 中查找课表表格
  253. * @param {number} intervalMs - 轮询间隔(毫秒)
  254. * @param {number} maxAttempts - 最大尝试次数
  255. * @returns {Promise<Document|null>} - 课表所在的 document,超时返回 null
  256. */
  257. async function pollUntilTableReady(intervalMs, maxAttempts) {
  258. intervalMs = intervalMs || 1000;
  259. maxAttempts = maxAttempts || 30;
  260. for (var i = 0; i < maxAttempts; i++) {
  261. var doc = getTimetableDocument();
  262. if (doc) return doc;
  263. if (i < maxAttempts - 1) await delay(intervalMs);
  264. }
  265. return null;
  266. }
  267. // ========== 主流程 ==========
  268. /**
  269. * 编排整个课程导入流程
  270. * 在任何一步用户取消或发生错误时,都会立即退出
  271. * AndroidBridge.notifyTaskCompletion() 只在完全成功后调用
  272. */
  273. async function runImportFlow() {
  274. // 步骤 1:提示用户
  275. const confirmed = await window.AndroidBridgePromise.showAlert(
  276. "USTC 课表导入",
  277. "请确保您已登录教务系统(jw.ustc.edu.cn)并处于课表查询页面。\n\n" +
  278. "操作步骤:\n" +
  279. "1. 登录教务系统\n" +
  280. "2. 进入「学生课表」页面\n" +
  281. "3. 选择需要导入的学期\n" +
  282. "4. 等待课表加载完成后点击「开始导入」",
  283. "好的,开始导入"
  284. );
  285. if (!confirmed) return;
  286. // 步骤 2:检查当前页面是否为教务系统
  287. if (!window.location.href.startsWith("https://jw.ustc.edu.cn")) {
  288. await window.AndroidBridgePromise.showAlert(
  289. "页面错误",
  290. "当前页面不是教务系统页面,请先登录并导航到「学生课表」页面。",
  291. "确定"
  292. );
  293. return;
  294. }
  295. // 步骤 3:轮询等待课表 DOM 加载完成(含 iframe 检测)
  296. AndroidBridge.showToast("正在等待课表加载...");
  297. var timetableDoc = await pollUntilTableReady();
  298. if (!timetableDoc) {
  299. AndroidBridge.showToast("未找到课表,请确认已进入「学生课表」页面且课表已加载");
  300. return;
  301. }
  302. // 步骤 4:解析课程数据(表格已加载,可能为空学期)
  303. var courses = parseCoursesFromDoc(timetableDoc);
  304. if (courses.length === 0) {
  305. AndroidBridge.showToast("该学期暂无课程数据");
  306. return;
  307. }
  308. // 步骤 5:提取学期信息(用于 Toast 提示)
  309. var semesterName = getSemesterName();
  310. // 步骤 6:保存时间段
  311. try {
  312. await window.AndroidBridgePromise.savePresetTimeSlots(
  313. JSON.stringify(getUSTCTimeSlots())
  314. );
  315. } catch (e) {
  316. AndroidBridge.showToast("保存时间段失败: " + e.message);
  317. return;
  318. }
  319. // 步骤 7:保存课程
  320. try {
  321. await window.AndroidBridgePromise.saveImportedCourses(
  322. JSON.stringify(courses)
  323. );
  324. } catch (e) {
  325. AndroidBridge.showToast("保存课程失败: " + e.message);
  326. return;
  327. }
  328. // 步骤 8:完成
  329. AndroidBridge.showToast("成功导入 " + courses.length + " 门课程(" + semesterName + ")");
  330. AndroidBridge.notifyTaskCompletion();
  331. }
  332. runImportFlow();