ustc_01.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340
  1. (function () {
  2. "use strict";
  3. function toast(message) {
  4. if (window.shiguangBridge && window.shiguangBridge.showToast) {
  5. window.shiguangBridge.showToast(message);
  6. } else {
  7. console.log("[USTC-GRAD]", message);
  8. }
  9. }
  10. async function alertUser(title, message) {
  11. if (window.shiguangBridgePromise && window.shiguangBridgePromise.showAlert) {
  12. return await window.shiguangBridgePromise.showAlert(title, message, "确定");
  13. }
  14. alert(title + "\n" + message);
  15. return true;
  16. }
  17. const HOST = "yjs1.ustc.edu.cn";
  18. const XNXQ_API = "/gsapp/sys/kbcxappustc/modules/xskbcx/xnxqxxcx.do";
  19. const KBCX_API = "/gsapp/sys/kbcxappustc/modules/xskbcx/xskbxxcx.do";
  20. function postForm(url, form) {
  21. return fetch(url, {
  22. method: "POST",
  23. credentials: "include",
  24. headers: { "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8" },
  25. body: new URLSearchParams(form).toString()
  26. }).then(function (resp) {
  27. if (!resp.ok) throw new Error("接口请求失败: HTTP " + resp.status + " (" + url + ")");
  28. return resp.text().then(function (text) {
  29. let data;
  30. try {
  31. data = JSON.parse(text);
  32. } catch (e) {
  33. // 未登录时接口返回登录页 HTML 而非 JSON
  34. throw new Error("请先登录研究生综合服务平台");
  35. }
  36. if (!data || data.code !== "0") {
  37. throw new Error((data && (data.msg || data.message)) || "接口返回异常 (" + url + ")");
  38. }
  39. return data;
  40. });
  41. });
  42. }
  43. /** 学期列表, 保留上游参数拼写 pageNumer */
  44. function fetchSemesterList() {
  45. return postForm(XNXQ_API, { SFSY: "1", pageSize: "100", pageNumer: "1" }).then(function (data) {
  46. const rows = (data.datas && data.datas.xnxqxxcx && data.datas.xnxqxxcx.rows) || [];
  47. return rows.filter(function (r) { return r && r.DM && r.MC; });
  48. });
  49. }
  50. function fetchTimetableRows(dm) {
  51. const querySetting = [{ name: "XNXQDM", linkOpt: "AND", builderList: "cbl_String", builder: "equal", value: dm }];
  52. return postForm(KBCX_API, {
  53. querySetting: JSON.stringify(querySetting),
  54. pageSize: "999",
  55. pageNumber: "1"
  56. }).then(function (data) {
  57. return (data.datas && data.datas.xskbxxcx && data.datas.xskbxxcx.rows) || [];
  58. });
  59. }
  60. function pad2(n) { return n < 10 ? "0" + n : "" + n; }
  61. function fmtDate(d) {
  62. return d.getFullYear() + "-" + pad2(d.getMonth() + 1) + "-" + pad2(d.getDate());
  63. }
  64. function mondayOf(dateStr) {
  65. const s = String(dateStr || "").slice(0, 10);
  66. if (!/^\d{4}-\d{2}-\d{2}$/.test(s)) return null;
  67. const d = new Date(s + "T00:00:00");
  68. if (isNaN(d.getTime())) return null;
  69. const day = d.getDay();
  70. d.setDate(d.getDate() - (day === 0 ? 6 : day - 1));
  71. return fmtDate(d);
  72. }
  73. function parseWeekText(text) {
  74. const weeks = [];
  75. String(text || "").split(/[,,]/).forEach(function (raw) {
  76. const part = raw.trim();
  77. if (!part) return;
  78. const parity = part.indexOf("单") >= 0 ? "odd" : (part.indexOf("双") >= 0 ? "even" : null);
  79. const range = part.match(/(\d+)\s*[~\-—–]\s*(\d+)/);
  80. let lo, hi;
  81. if (range) {
  82. lo = parseInt(range[1], 10);
  83. hi = parseInt(range[2], 10);
  84. } else {
  85. const single = part.match(/\d+/);
  86. if (!single) return;
  87. lo = hi = parseInt(single[0], 10);
  88. }
  89. if (isNaN(lo) || isNaN(hi)) return;
  90. for (let w = Math.min(lo, hi); w <= Math.max(lo, hi); w++) {
  91. if (parity === "odd" && w % 2 === 0) continue;
  92. if (parity === "even" && w % 2 === 1) continue;
  93. weeks.push(w);
  94. }
  95. });
  96. return weeks;
  97. }
  98. /**
  99. * 解析上课时间地点文本, 如 "GT-C102: 2(6,7,8);G3-113: 3(2,3,4)"
  100. * -> [{room:"GT-C102", day:2, sections:[6,7,8]}, {room:"G3-113", day:3, sections:[2,3,4]}]
  101. */
  102. function parseDateTimePlace(text) {
  103. const out = [];
  104. String(text || "").split(/[;;]/).forEach(function (raw) {
  105. const seg = raw.trim();
  106. if (!seg) return;
  107. const room = (seg.split(":")[0] || "").trim() || "未知地点";
  108. const re = /(\d+)\s*\(\s*([\d,,\-~\s]+?)\s*\)/g;
  109. let m;
  110. while ((m = re.exec(seg)) !== null) {
  111. const day = parseInt(m[1], 10);
  112. if (!(day >= 1 && day <= 7)) continue;
  113. const sections = [];
  114. m[2].split(/[,,]/).forEach(function (sRaw) {
  115. const s = sRaw.trim();
  116. if (!s) return;
  117. const r = s.match(/^(\d+)\s*[-~]\s*(\d+)$/);
  118. if (r) {
  119. for (let i = parseInt(r[1], 10); i <= parseInt(r[2], 10); i++) sections.push(i);
  120. } else {
  121. const n = parseInt(s, 10);
  122. if (!isNaN(n)) sections.push(n);
  123. }
  124. });
  125. if (sections.length) out.push({ room: room, day: day, sections: sections });
  126. }
  127. });
  128. return out;
  129. }
  130. function getUSTCTimeSlots() {
  131. return [
  132. { number: 1, startTime: "07:50", endTime: "08:35" },
  133. { number: 2, startTime: "08:40", endTime: "09:25" },
  134. { number: 3, startTime: "09:45", endTime: "10:30" },
  135. { number: 4, startTime: "10:35", endTime: "11:20" },
  136. { number: 5, startTime: "11:25", endTime: "12:10" },
  137. { number: 6, startTime: "14:00", endTime: "14:45" },
  138. { number: 7, startTime: "14:50", endTime: "15:35" },
  139. { number: 8, startTime: "15:55", endTime: "16:40" },
  140. { number: 9, startTime: "16:45", endTime: "17:30" },
  141. { number: 10, startTime: "17:35", endTime: "18:20" },
  142. { number: 11, startTime: "19:30", endTime: "20:15" },
  143. { number: 12, startTime: "20:20", endTime: "21:05" },
  144. { number: 13, startTime: "21:10", endTime: "21:55" }
  145. ];
  146. }
  147. function toLessonLike(row) {
  148. const name = String(row.KCMC || "").trim() || "未知课程";
  149. return {
  150. course: { nameZh: name },
  151. nameZh: name,
  152. weekText: { text: String(row.ZCMC || "") },
  153. dateTimePlace: { text: String(row.PKSJDD || "") },
  154. teachers: String(row.RKJS || "").split(/[,,、]/)
  155. .map(function (t) { return { nameZh: t.trim() }; })
  156. .filter(function (t) { return t.nameZh; })
  157. };
  158. }
  159. /**
  160. * 文本解析: 时间分段(教室: 星期(节次)) + 起止周文本 -> 课程数组
  161. * 周次分配: 分段数一致按位配对; 周次仅 1 段全部适用; 否则取并集并告警
  162. */
  163. function buildCoursesFromText(lessons, warns) {
  164. const courses = [];
  165. let maxWeek = 0;
  166. lessons.forEach(function (les) {
  167. const segs = parseDateTimePlace(les.dateTimePlace && les.dateTimePlace.text);
  168. if (!segs.length) {
  169. warns.push("课程「" + ((les.course && les.course.nameZh) || les.nameZh) + "」无法解析上课时间, 已跳过");
  170. return;
  171. }
  172. const weekSegs = String((les.weekText && les.weekText.text) || "").split(/[;;]/).filter(function (s) { return s.trim(); });
  173. let allWeeks = null;
  174. const weeksFor = function (i) {
  175. if (weekSegs.length === segs.length) return parseWeekText(weekSegs[i]);
  176. if (weekSegs.length === 1) return parseWeekText(weekSegs[0]);
  177. // 分段数不对应(分段为去重后的节次/周次)时, 取所有周次并集并告警
  178. if (!allWeeks) {
  179. allWeeks = [];
  180. weekSegs.forEach(function (w) { parseWeekText(w).forEach(function (x) { if (allWeeks.indexOf(x) < 0) allWeeks.push(x); }); });
  181. allWeeks.sort(function (a, b) { return a - b; });
  182. warns.push("课程「" + ((les.course && les.course.nameZh) || les.nameZh) + "」时间分段与周次分段数不一致, 周次可能不准确");
  183. }
  184. return allWeeks;
  185. };
  186. const name = (les.course && les.course.nameZh) || les.nameZh || "未知课程";
  187. const teacher = (les.teachers || []).map(function (t) { return t.nameZh || t.name; }).filter(Boolean).join("、") || "未知教师";
  188. segs.forEach(function (seg, i) {
  189. const weeks = weeksFor(i);
  190. if (!weeks.length) return;
  191. // 连续节次合并为一段
  192. const sorted = seg.sections.slice().sort(function (a, b) { return a - b; });
  193. let start = sorted[0], prev = sorted[0];
  194. const flush = function (end) {
  195. weeks.forEach(function (w) { if (w > maxWeek) maxWeek = w; });
  196. courses.push({
  197. name: name, teacher: teacher, position: seg.room,
  198. day: seg.day, startSection: start, endSection: end, weeks: weeks.slice()
  199. });
  200. };
  201. for (let k = 1; k < sorted.length; k++) {
  202. if (sorted[k] !== prev + 1) { flush(prev); start = sorted[k]; }
  203. prev = sorted[k];
  204. }
  205. flush(prev);
  206. });
  207. });
  208. return { courses: courses, maxWeek: maxWeek };
  209. }
  210. function dedupe(courses) {
  211. const seen = {};
  212. const out = [];
  213. courses.forEach(function (c) {
  214. const key = [c.name, c.teacher, c.position, c.day, c.startSection || c.customStartTime, c.endSection || c.customEndTime, c.weeks.join(",")].join("|");
  215. if (seen[key]) return;
  216. seen[key] = true;
  217. out.push(c);
  218. });
  219. return out.sort(function (a, b) {
  220. return a.day - b.day || (a.startSection || 0) - (b.startSection || 0);
  221. });
  222. }
  223. function buildCoursesFromRows(rows, warns) {
  224. const built = buildCoursesFromText(rows.map(toLessonLike), warns);
  225. return { courses: dedupe(built.courses), maxWeek: built.maxWeek };
  226. }
  227. function computeSemesterConfig(sem, maxWeek) {
  228. const config = { firstDayOfWeek: 7 };
  229. const zs = parseInt(sem && sem.ZS, 10);
  230. if (!isNaN(zs) && zs > 0) {
  231. config.semesterTotalWeeks = zs;
  232. } else if (maxWeek > 0) {
  233. config.semesterTotalWeeks = maxWeek;
  234. }
  235. const startDate = mondayOf((sem && (sem.TYKSRQ || sem.QSSJ || sem.ZCRQ)) || "");
  236. if (startDate) config.semesterStartDate = startDate;
  237. return config;
  238. }
  239. function pickDefaultSemesterIndex(rows) {
  240. for (let i = 0; i < rows.length; i++) {
  241. if (String(rows[i].SFDQXQ) === "1") return i;
  242. }
  243. return 0;
  244. }
  245. /**
  246. * 读取课表查询页当前显示的学期(页内「更改」切换后此处同步变化)
  247. * 页面通常位于同源 iframe 中, 标题形如: 我的课表 <label id="xnXqSpan">2026年秋季学期</label> 更改
  248. * 未找到时返回 null, 由调用方回退到当前学期
  249. */
  250. function getDisplayedSemesterName() {
  251. const docs = [document];
  252. const frames = document.querySelectorAll("iframe");
  253. for (let i = 0; i < frames.length; i++) {
  254. try {
  255. const d = frames[i].contentDocument || (frames[i].contentWindow && frames[i].contentWindow.document);
  256. if (d) docs.push(d);
  257. } catch (e) { /* 跨域跳过 */ }
  258. }
  259. for (let i = 0; i < docs.length; i++) {
  260. try {
  261. const el = docs[i].getElementById("xnXqSpan") ||
  262. docs[i].querySelector("h2 > label.bh-form-label");
  263. const t = el && (el.textContent || "").trim();
  264. if (t && /^\d{4}年/.test(t)) return t;
  265. } catch (e) { /* ignore */ }
  266. }
  267. return null;
  268. }
  269. async function runImportFlow() {
  270. try {
  271. const confirmed = await alertUser(
  272. "USTC 研究生课表导入",
  273. "请确保已登录研究生综合服务平台 yjs1.ustc.edu.cn\n且已进入「培养 → 课表查询 → 学生课表查询」页面",
  274. "开始导入"
  275. );
  276. if (!confirmed) {
  277. toast("用户取消了导入。");
  278. return;
  279. }
  280. if (location.hostname !== HOST) {
  281. toast("请在 yjs1.ustc.edu.cn 页面执行导入。");
  282. return;
  283. }
  284. toast("正在获取课程数据...");
  285. const semesters = await fetchSemesterList();
  286. const displayed = getDisplayedSemesterName();
  287. let sem = displayed && semesters.find(function (s) { return s.MC === displayed; });
  288. if (!sem) sem = semesters[pickDefaultSemesterIndex(semesters)];
  289. if (!sem) {
  290. toast("未获取到学期信息,请确认已登录。");
  291. return;
  292. }
  293. const rows = await fetchTimetableRows(sem.DM);
  294. if (!rows.length) {
  295. toast("该学期暂无课程数据。");
  296. return;
  297. }
  298. const warns = [];
  299. const built = buildCoursesFromRows(rows, warns);
  300. if (!built.courses.length) {
  301. toast("未解析到课程,请确认课表已正确加载。");
  302. return;
  303. }
  304. // 保存: 学期配置 -> 作息时间 -> 课程
  305. await window.shiguangBridgePromise.saveCourseConfig(JSON.stringify(computeSemesterConfig(sem, built.maxWeek)));
  306. await window.shiguangBridgePromise.savePresetTimeSlots(JSON.stringify(getUSTCTimeSlots()));
  307. await window.shiguangBridgePromise.saveImportedCourses(JSON.stringify(built.courses));
  308. toast("课程导入成功,共导入 " + built.courses.length + " 条课程(" + sem.MC + ")!");
  309. if (warns.length) console.warn("[USTC-GRAD]", warns.join(";"));
  310. window.shiguangBridge.notifyTaskCompletion();
  311. } catch (error) {
  312. console.error("[USTC-GRAD] import failed:", error);
  313. const msg = error && error.message ? error.message : String(error);
  314. toast(msg.indexOf("请先登录") >= 0 ? "请先登录研究生综合服务平台 yjs1.ustc.edu.cn" : "导入失败: " + msg);
  315. }
  316. }
  317. runImportFlow();
  318. })();