upc_graduate.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293
  1. // 中国石油大学(华东)研究生综合管理系统(degrees.upc.edu.cn)拾光课表导入适配脚本
  2. // ASP.NET WebForms(/Gstudent/Course/StuCourseQuery.aspx):
  3. // 预设作息时间(与中国石油大学华东本科一致,12 节)
  4. const UPC_TIME_SLOTS = [
  5. { number: 1, startTime: "08:00", endTime: "08:45" },
  6. { number: 2, startTime: "08:50", endTime: "09:35" },
  7. { number: 3, startTime: "09:55", endTime: "10:40" },
  8. { number: 4, startTime: "10:45", endTime: "11:30" },
  9. { number: 5, startTime: "11:35", endTime: "12:20" },
  10. { number: 6, startTime: "14:00", endTime: "14:45" },
  11. { number: 7, startTime: "14:50", endTime: "15:35" },
  12. { number: 8, startTime: "15:55", endTime: "16:40" },
  13. { number: 9, startTime: "16:45", endTime: "17:30" },
  14. { number: 10, startTime: "19:00", endTime: "19:45" },
  15. { number: 11, startTime: "19:50", endTime: "20:35" },
  16. { number: 12, startTime: "20:40", endTime: "21:25" }
  17. ];
  18. function getErrorMessage(error) {
  19. if (error && typeof error.message === "string" && error.message.trim()) return error.message;
  20. if (typeof error === "string" && error.trim()) return error;
  21. try {
  22. const serialized = JSON.stringify(error);
  23. if (serialized && serialized !== "{}") return serialized;
  24. } catch (_) {
  25. // Ignore serialization failures and use the generic fallback below.
  26. }
  27. return "未知错误";
  28. }
  29. // 解析周次文本:"2-12"、"14"、"15-16" → [2,3,...,12] 等
  30. function parseWeeksText(text) {
  31. const weeks = new Set();
  32. String(text || "").split(/、|,/).forEach(part => {
  33. part = part.trim();
  34. if (!part) return;
  35. const range = part.match(/^(\d+)-(\d+)$/);
  36. if (range) {
  37. const [lo, hi] = Number(range[1]) < Number(range[2])
  38. ? [Number(range[1]), Number(range[2])]
  39. : [Number(range[2]), Number(range[1])];
  40. for (let i = lo; i <= hi; i++) weeks.add(i);
  41. } else if (/^\d+$/.test(part)) {
  42. weeks.add(Number(part));
  43. }
  44. });
  45. return Array.from(weeks).sort((a, b) => a - b);
  46. }
  47. // 从当前页及同源 iframe 收集 document
  48. function collectDocs() {
  49. const docs = [document];
  50. document.querySelectorAll("iframe").forEach(f => {
  51. try {
  52. if (f.contentDocument && f.contentDocument.getElementById) docs.push(f.contentDocument);
  53. } catch (_) {
  54. // Cross-origin iframe, skip.
  55. }
  56. });
  57. return docs;
  58. }
  59. // 找到课表表格
  60. function findCourseTable() {
  61. for (const doc of collectDocs()) {
  62. const table = doc.getElementById("ctl00_contentParent_dgData");
  63. if (table) return table;
  64. }
  65. return null;
  66. }
  67. // 解析格子内的一门门课文本为课程数组
  68. // 文本示例:应用统计方法与数据科学储建班{2-12周[教师:曹晓敏,地点:东廊302]}
  69. // 模式分类与学习1班{14周[教师:刘伟锋]、15-16周[教师:杨兴浩][地点:南堂201]}
  70. function parseCellText(text, day, startSection, endSection, courses) {
  71. const parts = String(text || "").split(";");
  72. parts.forEach(part => {
  73. part = part.trim();
  74. if (!part) return;
  75. const braceIdx = part.indexOf("{");
  76. if (braceIdx === -1) return;
  77. const name = part.slice(0, braceIdx).replace(/\s+/g, " ").trim();
  78. if (!name) return;
  79. const body = part.slice(braceIdx + 1, part.lastIndexOf("}") === -1 ? part.length : part.lastIndexOf("}"));
  80. // 提取公共地点(无教师前缀的 [地点:X],通常在全段末尾)
  81. let commonRoom = null;
  82. const commonMatch = body.match(/\[地点:([^\]]+)\]/g);
  83. if (commonMatch && commonMatch.length > 0) {
  84. const last = commonMatch[commonMatch.length - 1].match(/\[地点:([^\]]+)\]/)[1];
  85. commonRoom = last.trim();
  86. }
  87. // 先取出整段 [教师:...] 容器,完整匹配教师名;地点可能在其内或独立段
  88. const segRe = /([\d,\-、]+)周\s*\[教师:([^\]]+)\]/g;
  89. let seg, found = false;
  90. while ((seg = segRe.exec(body)) !== null) {
  91. const weeks = parseWeeksText(seg[1]);
  92. if (weeks.length === 0) continue;
  93. const teacherBody = seg[2].trim();
  94. // 教师:地点:,(兼容半角/全角逗号/空格)
  95. const commaIdx = teacherBody.search(/[,,]/);
  96. const teacher = (commaIdx === -1 ? teacherBody : teacherBody.slice(0, commaIdx)).trim();
  97. // 段内地点([教师:X,地点:Y] 或 [教师:X,地点:Y]),从含 ] 的整段取
  98. const roomMatch = seg[0].match(/地点:([^\]]+)\]/);
  99. const room = roomMatch ? roomMatch[1].trim() : commonRoom;
  100. courses.push({
  101. name,
  102. teacher: teacher || "未知",
  103. position: room || "待定",
  104. day,
  105. startSection,
  106. endSection,
  107. weeks
  108. });
  109. found = true;
  110. }
  111. if (!found) {
  112. return;
  113. }
  114. });
  115. }
  116. // 解析课表表格
  117. function parseCourseTableFromDom(table) {
  118. const courses = [];
  119. // 每行中节次号之后的 7 个 td,按相对顺序为 星期日|星期一|…|星期六
  120. // 拾光 day:1 表示星期一,7 表示星期日
  121. const dayByOffset = { 0: 7, 1: 1, 2: 2, 3: 3, 4: 4, 5: 5, 6: 6 };
  122. table.querySelectorAll("tbody tr").forEach(row => {
  123. const tds = row.querySelectorAll("td");
  124. // 动态定位节次号 td(内容为纯数字 1~12),其后 7 个 td 为周日~周六
  125. // 注意:"上午/下午/晚上" 是 rowspan 合并单元格,仅每大节首行存在,故不能固定列索引
  126. let secIndex = -1;
  127. let startSection = 0;
  128. for (let i = 0; i < tds.length; i++) {
  129. const t = Number((tds[i].textContent || "").trim());
  130. if (t >= 1 && t <= 12) { secIndex = i; startSection = t; break; }
  131. }
  132. if (secIndex === -1) return;
  133. for (let off = 0; off < 7; off++) {
  134. const td = tds[secIndex + 1 + off];
  135. if (!td) continue;
  136. const text = (td.textContent || "").replace(/\s+/g, " ").trim();
  137. if (!text || text.indexOf("{") === -1) continue;
  138. const rowspan = Number(td.getAttribute("rowspan")) || 1;
  139. const endSection = startSection + rowspan - 1;
  140. parseCellText(text, dayByOffset[off], startSection, endSection, courses);
  141. }
  142. });
  143. return courses;
  144. }
  145. // 合并相同课程(同名/同师/同地/同星期/同节次)的周次
  146. function mergeCourses(courses) {
  147. const map = new Map();
  148. for (const c of courses) {
  149. const key = `${c.name}|${c.teacher}|${c.position}|${c.day}|${c.startSection}|${c.endSection}`;
  150. if (map.has(key)) {
  151. const existing = map.get(key);
  152. existing.weeks = Array.from(new Set([...existing.weeks, ...c.weeks])).sort((a, b) => a - b);
  153. } else {
  154. map.set(key, c);
  155. }
  156. }
  157. return Array.from(map.values());
  158. }
  159. // 保存作息时间(失败仅告警)
  160. async function saveTimeSlots(timeSlots) {
  161. if (!timeSlots || timeSlots.length === 0) return;
  162. try {
  163. await window.shiguangBridgePromise.savePresetTimeSlots(JSON.stringify(timeSlots));
  164. } catch (error) {
  165. console.error("JS: 作息时间保存失败", error);
  166. }
  167. }
  168. // 从教学日历页面解析开学日期与总周数
  169. // 表格 ctl00_contentParent_dgData:每行一个周次,首列=星期日,每格为 日期(月份)
  170. // 开学日期取第 1 周星期一所对应的完整日期;总周数 = 数据行数
  171. function parseTermCalendar(html, currentSemester) {
  172. const doc = new DOMParser().parseFromString(html, "text/html");
  173. const table = doc.getElementById("ctl00_contentParent_dgData");
  174. if (!table) return null;
  175. const rows = table.querySelectorAll("tbody tr, tr");
  176. const dates = [];
  177. let totalWeeks = 0;
  178. rows.forEach(row => {
  179. const cells = row.querySelectorAll("td");
  180. if (cells.length < 8) return;
  181. // cells[0]=周次,cells[1..7]=星期日..星期六
  182. const rowWeeks = [];
  183. for (let i = 1; i <= 7; i++) {
  184. const span = cells[i].querySelector("span");
  185. const m = (span ? span.textContent : "").match(/(\d+)\s*\((\d+)月\)/);
  186. if (m) rowWeeks[i] = { day: Number(m[1]), month: Number(m[2]) };
  187. }
  188. if (Object.keys(rowWeeks).length > 0) {
  189. dates.push(rowWeeks);
  190. totalWeeks++;
  191. }
  192. });
  193. if (totalWeeks === 0) return null;
  194. // 用当前年份推算各日期所在年份:月份<=2 属于上一自然年,否则为当前年
  195. const currentYear = new Date().getFullYear();
  196. const firstRow = dates[0];
  197. // 第 1 周 星期一 = 列 index2(cells[2])
  198. const monday = firstRow[2];
  199. if (!monday) return { semesterTotalWeeks: totalWeeks };
  200. const yearOf = (month) => (month <= 2 ? currentYear - 1 : currentYear);
  201. const mm = String(monday.month).padStart(2, "0");
  202. const dd = String(monday.day).padStart(2, "0");
  203. return {
  204. semesterStartDate: `${yearOf(monday.month)}-${mm}-${dd}`,
  205. semesterTotalWeeks: totalWeeks
  206. };
  207. }
  208. // 请求教学日历页面获取学期配置
  209. async function fetchSemesterConfig() {
  210. try {
  211. // 从当前页面找到教学日历链接(#hykTermCalender 的 onclick 含 EID)
  212. let eid = null;
  213. for (const doc of collectDocs()) {
  214. const link = doc.getElementById("hykTermCalender") || doc.querySelector("a[onclick*='TermCalender.aspx']");
  215. if (link) {
  216. const onclick = link.getAttribute("onclick") || "";
  217. const m = onclick.match(/TermCalender\.aspx\?EID=([^&'"]+)/);
  218. if (m) { eid = m[1]; break; }
  219. }
  220. }
  221. if (!eid) return null;
  222. const url = `/PublicPage/TermCalender.aspx?EID=${encodeURIComponent(eid)}&UID=`;
  223. const response = await fetch(url, { credentials: "include" });
  224. if (!response.ok) return null;
  225. return parseTermCalendar(await response.text());
  226. } catch (error) {
  227. console.error("JS: 教学日历请求失败", error);
  228. return null;
  229. }
  230. }
  231. async function runImportFlow() {
  232. const alertConfirmed = await window.shiguangBridgePromise.showAlert(
  233. "研究生课表导入",
  234. "导入前请确保您已登录并打开课表查询页面(StuCourseQuery)",
  235. "好的,开始导入"
  236. );
  237. if (!alertConfirmed) {
  238. window.shiguangBridge.showToast("用户取消了导入。");
  239. return;
  240. }
  241. window.shiguangBridge.showToast("正在解析课表...");
  242. try {
  243. const table = findCourseTable();
  244. if (!table) throw new Error("未找到课表表格(ctl00_contentParent_dgData),请确认已打开课表页面。");
  245. const courses = parseCourseTableFromDom(table);
  246. if (courses.length === 0) throw new Error("课表中未解析到有效课程,请确认当前学期有课。");
  247. const merged = mergeCourses(courses);
  248. window.shiguangBridge.showToast(`正在保存 ${merged.length} 门课程...`);
  249. await window.shiguangBridgePromise.saveImportedCourses(JSON.stringify(merged, null, 2));
  250. // 学期配置优先从教学日历取,失败时以课程最大周次兜底
  251. const cal = await fetchSemesterConfig();
  252. const config = {
  253. semesterTotalWeeks: (cal && cal.semesterTotalWeeks) ||
  254. merged.reduce((m, c) => Math.max(m, (c.weeks[c.weeks.length - 1] || 0)), 0)
  255. };
  256. if (cal && cal.semesterStartDate) config.semesterStartDate = cal.semesterStartDate;
  257. await window.shiguangBridgePromise.saveCourseConfig(JSON.stringify(config));
  258. await saveTimeSlots(UPC_TIME_SLOTS);
  259. window.shiguangBridge.showToast(`课程导入成功,共导入 ${merged.length} 门课程!`);
  260. window.shiguangBridge.notifyTaskCompletion();
  261. } catch (error) {
  262. window.shiguangBridge.showToast(`导入失败:${getErrorMessage(error)}`);
  263. console.error("JS: Import Error", error);
  264. }
  265. }
  266. runImportFlow();