| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855 |
- // 江苏大学(ujs.edu.cn)研究生管理系统 拾光课程表适配脚本
- // 基于正方研究生管理系统(研究生版 Gmis / pyxx)适配
- // 出现问题请联系作者或者提交直接pr更改,这更加快速
- // 作者:洛初 Github@gongfuture
- // 2026.09.10 第一版
- // 研究生系统没有对外直连入口,需先经统一身份认证(ehall 门户或 http://yjsgll.ujs.edu.cn/pyxx/)登录。
- // 课表从学生课表查询页 pygl/kbcx_xs.aspx 的 DataGrid 里解析:
- // 单元格文本自带「第3-18周 单双周:星期二 上3,4 星期五 上1,2」,星期、节次、周次都能直接取到。
- // 作息时间取自课表页下方的作息表(上午/下午/晚上),由服务端按当前令时渲染,脚本原样导入,
- // 因此不再像本科脚本那样询问夏令时/冬令时,也不做楼栋匹配(研究生系统全校一套作息)。(后续看十一再调整)
- // 开学日期:研究生系统没有校历接口,改用「我的课表」接口 App_Ajax/GetkcHandler.ashx 逐周回查,
- // 找到某门课第一次出现的日期,再按(起始周-1)*7+(星期-1)反推开学日期;反推不到则跳过配置保存。
- /**
- * 数组过滤(原生实现)。
- * 部分教务页面会改写 Array.prototype.filter,这里用普通循环自行实现,不依赖被改写过的数组方法。
- */
- function arrayFilter(arr, predicate) {
- const result = [];
- for (let i = 0; i < arr.length; i++) {
- if (predicate(arr[i], i, arr)) {
- result.push(arr[i]);
- }
- }
- return result;
- }
- /**
- * 拼接教务系统接口地址。
- * 校内直连时 location.origin 就是教务域名,前缀为空;
- * 校外经 WebVPN 访问时路径带有 /http/<hex> 前缀,必须保留,否则会变成跨域请求。
- */
- function buildApiUrl(path) {
- const prefixMatch = window.location.pathname.match(/^\/http\/[0-9a-f]+/i);
- const prefix = prefixMatch ? prefixMatch[0] : "";
- return window.location.origin + prefix + path;
- }
- /**
- * 当前是否在研究生管理系统内。
- * 校内直连域名是 yjsgll.ujs.edu.cn,校外经 WebVPN 时是 webvpn.ujs.edu.cn/http/<hex>/pyxx/。
- */
- function isGraduateSystemPage() {
- const host = window.location.hostname;
- if (host.indexOf("yjsgll.ujs.edu.cn") !== -1) return true;
- return /\/http\/[0-9a-f]+\/pyxx\//i.test(window.location.pathname);
- }
- /**
- * 是否停在登录页。
- * 未登录访问会被重定向到统一身份认证 https://pass.ujs.edu.cn/cas/login?service=.../pyxx/loginCAS.aspx。
- */
- function isLoginPage() {
- const host = window.location.hostname;
- if (host.indexOf("pass.ujs.edu.cn") !== -1) return true;
- return window.location.pathname.indexOf("/cas/login") !== -1;
- }
- /**
- * 解析周次文本,返回周次数组。
- * 研究生系统的写法是「第3-18周」,也可能出现「第3周」;
- * 单双周标记要先把「单双周」这个词本身剔掉,否则会被误判成单周。
- */
- function parseWeeks(weekText) {
- if (!weekText) return [];
- const cleaned = String(weekText).replace(/单双周/g, "");
- const oddOnly = cleaned.indexOf("单") !== -1;
- const evenOnly = cleaned.indexOf("双") !== -1;
- const weeks = [];
- const rangePattern = /(\d+)\s*-\s*(\d+)\s*周|(\d+)\s*周/g;
- let match;
- while ((match = rangePattern.exec(cleaned)) !== null) {
- const start = Number(match[1] || match[3]);
- const end = match[2] ? Number(match[2]) : start;
- for (let week = start; week <= end; week++) {
- if (oddOnly && week % 2 === 0) continue;
- if (evenOnly && week % 2 !== 0) continue;
- weeks.push(week);
- }
- }
- return Array.from(new Set(weeks)).sort((a, b) => a - b);
- }
- /**
- * 节次与周次合并去重(参考官方 wiki 课程合并与去重函数)。
- * 课表里同一门课的每个上课时段都会重复一份完整文本,
- * 解析后会得到多条完全相同的记录,这里统一合并/去重。
- */
- function mergeAndDistinctCourses(courses) {
- if (!Array.isArray(courses) || courses.length <= 1) return courses;
- const list = courses.map(c => ({
- ...c,
- name: c.name || "",
- teacher: c.teacher || "",
- position: c.position || "",
- weeks: Array.isArray(c.weeks) ? [...c.weeks].sort((a, b) => a - b) : []
- }));
- // 阶段 1:合并连续节次与完全重复记录(前提:名称、教师、地点、星期、周次一致)
- list.sort((a, b) => {
- return a.name.localeCompare(b.name) ||
- a.teacher.localeCompare(b.teacher) ||
- a.position.localeCompare(b.position) ||
- (a.day || 0) - (b.day || 0) ||
- a.weeks.join(",").localeCompare(b.weeks.join(",")) ||
- (a.startSection || 0) - (b.startSection || 0);
- });
- const step1Merged = [];
- let current = list[0];
- for (let i = 1; i < list.length; i++) {
- const next = list[i];
- const isSameCourseAndWeeks =
- current.name === next.name &&
- current.teacher === next.teacher &&
- current.position === next.position &&
- current.day === next.day &&
- current.weeks.join(",") === next.weeks.join(",");
- const isContinuous = current.endSection + 1 === next.startSection;
- const isDuplicate = current.startSection === next.startSection && current.endSection === next.endSection;
- if (isSameCourseAndWeeks && isContinuous) {
- current.endSection = next.endSection;
- } else if (isSameCourseAndWeeks && isDuplicate) {
- continue;
- } else {
- step1Merged.push(current);
- current = next;
- }
- }
- step1Merged.push(current);
- // 阶段 2:合并同节次的周次(前提:名称、教师、地点、星期、开始/结束节次一致)
- step1Merged.sort((a, b) => {
- return a.name.localeCompare(b.name) ||
- a.teacher.localeCompare(b.teacher) ||
- a.position.localeCompare(b.position) ||
- (a.day || 0) - (b.day || 0) ||
- (a.startSection || 0) - (b.startSection || 0) ||
- (a.endSection || 0) - (b.endSection || 0);
- });
- const step2Merged = [];
- let cur = step1Merged[0];
- for (let i = 1; i < step1Merged.length; i++) {
- const nxt = step1Merged[i];
- const isSameCourseAndSection =
- cur.name === nxt.name &&
- cur.teacher === nxt.teacher &&
- cur.position === nxt.position &&
- cur.day === nxt.day &&
- cur.startSection === nxt.startSection &&
- cur.endSection === nxt.endSection;
- if (isSameCourseAndSection) {
- cur.weeks = Array.from(new Set([...cur.weeks, ...nxt.weeks])).sort((a, b) => a - b);
- } else {
- step2Merged.push(cur);
- cur = nxt;
- }
- }
- step2Merged.push(cur);
- return step2Merged;
- }
- /**
- * 取单元格文本。
- * 课表每个格子里是 <br> 分行的文本,DOMParser 解析出来的文档没有 innerText,
- * 先把 <br> 换成换行再取 textContent。
- */
- function readCellText(cell) {
- const clone = cell.cloneNode(true);
- Array.from(clone.querySelectorAll("br")).forEach(br => br.replaceWith("\n"));
- return clone.textContent.replace(/ /g, " ").replace(/\r/g, "");
- }
- /**
- * 把「上3,4」「晚9,10,11」里的节次换算成全天连续节次。
- * 研究生系统文本里的节次就是全天连续编号(晚上从 9 开始),
- * 但个别部署可能写成段内编号(下1,2 / 晚1,2,3),这里两种写法都兼容。
- */
- function normalizeSection(periodPrefix, number) {
- if (periodPrefix === "下") return number <= 4 ? number + 4 : number;
- if (periodPrefix === "晚") return number <= 3 ? number + 8 : number;
- return number;
- }
- const CHINESE_WEEKDAY_MAP = {
- "一": 1, "二": 2, "三": 3, "四": 4, "五": 5, "六": 6, "日": 7, "天": 7
- };
- /**
- * 解析一个课表单元格的文本。
- * 典型内容:
- * 课程:ESP(专门用途英语)
- * 班级:ESP(专门用途英语)5班理工
- * (Y206(M) )
- * 第3-18周 单双周:星期二 上3,4 星期五 上1,2
- * 主讲教师:史莹娟
- * 一门课在同一格里会列出全部上课时段(上面的例子就是周二 + 周五两段),
- * 所以一个单元格可能产出多条课程记录。
- */
- function parseCourseCellText(text) {
- const lines = String(text).split("\n").map(line => line.trim()).filter(line => line.length > 0);
- let name = "";
- let className = "";
- let teacher = "";
- let position = "";
- let scheduleLine = "";
- for (const line of lines) {
- if (/^课程[::]/.test(line)) {
- name = line.replace(/^课程[::]\s*/, "").trim();
- continue;
- }
- if (/^班级[::]/.test(line)) {
- className = line.replace(/^班级[::]\s*/, "").trim();
- continue;
- }
- if (/^主讲教师[::]/.test(line)) {
- teacher = line.replace(/^主讲教师[::]\s*/, "").trim();
- continue;
- }
- if (/^[((].*[))]$/.test(line)) {
- // 教室整行被括号包住,去掉最外层括号(教室内名可能自带括号,如 Y206(M))
- position = line.replace(/^[((]/, "").replace(/[))]$/, "").trim();
- continue;
- }
- if (line.indexOf("周") !== -1) {
- scheduleLine = scheduleLine ? `${scheduleLine} ${line}` : line;
- }
- }
- // 名称、周次、上课时段是排课必需的,缺一不可
- if (!name || !scheduleLine) return [];
- const weeks = parseWeeks(scheduleLine);
- if (weeks.length === 0) return [];
- const sessionPattern = /星期([一二三四五六日天])\s*([上下晚])\s*([0-9,,、\s]+)/g;
- const records = [];
- let match;
- while ((match = sessionPattern.exec(scheduleLine)) !== null) {
- const day = CHINESE_WEEKDAY_MAP[match[1]];
- if (!day) continue;
- const numbers = arrayFilter(
- match[3].split(/[^0-9]+/).map(item => Number(item)),
- (item) => !isNaN(item) && item > 0
- );
- if (numbers.length === 0) continue;
- const sections = numbers.map(number => normalizeSection(match[2], number)).sort((a, b) => a - b);
- const startSection = sections[0];
- const endSection = sections[sections.length - 1];
- if (startSection < 1 || endSection > 20) continue;
- const remarkParts = [];
- if (className) remarkParts.push(`班级:${className}`);
- if (scheduleLine) remarkParts.push(scheduleLine.trim());
- records.push({
- name: name,
- teacher: teacher,
- position: position,
- day: day,
- startSection: startSection,
- endSection: endSection,
- weeks: weeks,
- remark: remarkParts.join(" | ")
- });
- }
- return records;
- }
- /**
- * 解析课表页 #MainWork_DataGrid1 里的全部课程。
- * 每个单元格自带完整文本(含星期、节次、周次),不必按表格行列还原,
- * 重复出现的记录交给 mergeAndDistinctCourses 去重。
- */
- function parseCoursesFromGrid(doc) {
- const grid = doc.querySelector("#MainWork_DataGrid1");
- if (!grid) return [];
- const courses = [];
- for (const cell of Array.from(grid.querySelectorAll("td"))) {
- const text = readCellText(cell);
- if (text.indexOf("课程") === -1) continue;
- courses.push(...parseCourseCellText(text));
- }
- return mergeAndDistinctCourses(courses);
- }
- /**
- * 解析课表页下方的作息表,得到按全天连续编号的预设时间段。
- * 上午 dgListSw / 下午 dgListXw / 晚上 dgListWs 三张表,
- * 每行形如「第一节课 8:00-8:45」;编号按上午、下午、晚上依次顺延,
- * 与课表文本里的连续节次编号一致。
- */
- function parseTimeSlots(doc) {
- const groups = [
- { id: "#MainWork_dgListSw", label: "上午" },
- { id: "#MainWork_dgListXw", label: "下午" },
- { id: "#MainWork_dgListWs", label: "晚上" }
- ];
- const slots = [];
- let number = 1;
- for (const group of groups) {
- const table = doc.querySelector(group.id);
- if (!table) continue;
- for (const cell of Array.from(table.querySelectorAll("td"))) {
- // 作息表是「单元格里再套一层表」的结构,外层单元格的 textContent 也含时间,
- // 这里只取最内层单元格,避免同一条时间被重复计数。
- if (cell.querySelector("table")) continue;
- const match = cell.textContent.match(/(\d{1,2}):(\d{2})\s*-\s*(\d{1,2}):(\d{2})/);
- if (!match) continue;
- const pad = (value) => String(value).padStart(2, "0");
- slots.push({
- number: number,
- startTime: `${pad(match[1])}:${match[2]}`,
- endTime: `${pad(match[3])}:${match[4]}`
- });
- number += 1;
- }
- }
- return slots;
- }
- /**
- * 读取下拉框当前选中项的值。
- * DOMParser 解析出的文档不一定支持 select.value,这里按 selected 属性自行判断。
- */
- function getSelectedOptionValue(select) {
- if (!select || !select.options || select.options.length === 0) return "";
- for (const option of Array.from(select.options)) {
- if (option.hasAttribute("selected")) return option.value;
- }
- return select.options[0].value;
- }
- /**
- * 收集表单里可提交的字段,模拟一次完整回发。
- * ASP.NET 的 __VIEWSTATE 等方法不能少,逐个手写容易漏,这里整体搬运后按需覆盖。
- */
- function collectPostbackFields(doc, overrides) {
- const params = new URLSearchParams();
- const form = doc.querySelector("#form1") || doc.querySelector("form");
- if (!form) return params;
- for (const el of Array.from(form.querySelectorAll("input[name], select[name], textarea[name]"))) {
- const tag = el.tagName.toLowerCase();
- const name = el.getAttribute("name");
- if (!name) continue;
- if (tag === "select") {
- params.set(name, getSelectedOptionValue(el));
- continue;
- }
- const type = (el.getAttribute("type") || "text").toLowerCase();
- if (type === "submit" || type === "image" || type === "button" || type === "reset") {
- // 图片按钮/提交按钮需要 .x / .y,或者完全不提交,这里跳过由调用方补充
- continue;
- }
- if (type === "checkbox" || type === "radio") {
- if (!el.hasAttribute("checked")) continue;
- }
- params.set(name, el.getAttribute("value") || "");
- }
- Object.keys(overrides || {}).forEach(key => params.set(key, overrides[key]));
- return params;
- }
- /**
- * 以 form 表单方式回发并返回响应 HTML。
- */
- async function postForm(url, params) {
- const response = await fetch(url, {
- method: "POST",
- headers: { "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8" },
- body: params.toString(),
- credentials: "include"
- });
- if (!response.ok) {
- throw new Error(`回发失败,状态码 ${response.status}`);
- }
- return await response.text();
- }
- /**
- * 读取指定学期的课表页文档。
- * 课表页默认渲染系统当前学期;要查历史/其它学期得按页面自身的逻辑回发两次:
- * 先把学期下拉框回发一次(换出新的 __VIEWSTATE),再点一次「查询」按钮重新绑定课表。
- * @param {string} semesterValue 学期下拉框的 value,为空表示用默认(当前)学期
- */
- async function fetchTimetableDocument(semesterValue) {
- const url = buildApiUrl("/pyxx/pygl/kbcx_xs.aspx");
- const pageResponse = await fetch(url, { method: "GET", credentials: "include" });
- const pageDoc = new DOMParser().parseFromString(await pageResponse.text(), "text/html");
- const semesterSelect = pageDoc.querySelector("#MainWork_drpxq");
- if (!semesterSelect) {
- throw new Error("NOT_LOGGED_IN");
- }
- const currentValue = getSelectedOptionValue(semesterSelect);
- if (!semesterValue || semesterValue === currentValue) {
- return pageDoc;
- }
- // 第一步:学期下拉框回发,拿到与新选中项对应的 __VIEWSTATE
- const firstParams = collectPostbackFields(pageDoc, {
- "__EVENTTARGET": "ctl00$MainWork$drpxq",
- "__EVENTARGUMENT": "",
- "ctl00$MainWork$drpxq": semesterValue
- });
- const afterChangeDoc = new DOMParser().parseFromString(await postForm(url, firstParams), "text/html");
- // 第二步:点「查询」按钮,课表才按新学期的选项重新绑定
- const secondParams = collectPostbackFields(afterChangeDoc, {
- "__EVENTTARGET": "",
- "__EVENTARGUMENT": "",
- "ctl00$MainWork$drpxq": semesterValue,
- "ctl00$MainWork$btnSearch.x": "5",
- "ctl00$MainWork$btnSearch.y": "5"
- });
- const resultDoc = new DOMParser().parseFromString(await postForm(url, secondParams), "text/html");
- if (!resultDoc.querySelector("#MainWork_DataGrid1")) {
- throw new Error("POSTBACK_FAILED");
- }
- return resultDoc;
- }
- /**
- * 读取学期下拉框的全部选项。
- */
- function readSemesterOptions(doc) {
- const select = doc.querySelector("#MainWork_drpxq");
- if (!select) return null;
- const options = Array.from(select.options)
- .map(option => ({
- value: option.value,
- text: option.textContent.trim(),
- selected: option.hasAttribute("selected")
- }))
- .filter(option => option.text);
- if (options.length === 0) return null;
- let defaultIndex = options.findIndex(option => option.selected);
- if (defaultIndex === -1) defaultIndex = 0;
- return { options: options, defaultIndex: defaultIndex };
- }
- function formatDate(date) {
- const pad = (value) => String(value).padStart(2, "0");
- return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
- }
- function addDays(date, days) {
- const result = new Date(date.getFullYear(), date.getMonth(), date.getDate());
- result.setDate(result.getDate() + days);
- return result;
- }
- /**
- * 查询某天「我的课表」接口,判断目标课程当天是否上课。
- * App_Ajax/GetkcHandler.ashx 是首页「我的课表」按日期查询用的接口,
- * 传入日期就返回当天课程,可以用来反推开学日期。
- */
- async function hasCourseOn(date, courseName, studentId) {
- const url = buildApiUrl(
- `/pyxx/App_Ajax/GetkcHandler.ashx?kcdate=${formatDate(date)}&xh=${encodeURIComponent(studentId)}`
- );
- const response = await fetch(url, { method: "GET", credentials: "include" });
- if (!response.ok) return false;
- const doc = new DOMParser().parseFromString(await response.text(), "text/html");
- const text = (doc.body ? doc.body.textContent : "").replace(/\s+/g, "");
- return text.indexOf(String(courseName).replace(/\s+/g, "")) !== -1;
- }
- /**
- * 找到参照日期:距离今天最近的一个「目标星期」的日期(今天或之前)。
- * 星期编号 1-7 对应周一到周日。
- */
- function findReferenceDate(day) {
- const today = new Date();
- const jsDay = today.getDay(); // 0=周日
- const todayDay = jsDay === 0 ? 7 : jsDay;
- let diff = todayDay - day;
- if (diff < 0) diff += 7;
- return addDays(today, -diff);
- }
- /**
- * 挑选用于探测的课程:起始周最早、周次跨度最短的那门,
- * 跨度短意味着要核对的周次少。
- */
- function pickProbeCourse(courses) {
- let best = null;
- for (const course of courses) {
- if (!course.weeks || course.weeks.length === 0) continue;
- const weeks = course.weeks;
- const span = weeks[weeks.length - 1] - weeks[0];
- if (!best ||
- weeks[0] < best.weeks[0] ||
- (weeks[0] === best.weeks[0] && span < best.span)) {
- best = { name: course.name, day: course.day, weeks: weeks, span: span };
- }
- }
- return best;
- }
- /**
- * 反推本学期开学日期。
- *
- * 研究生系统没有校历接口,只能借「我的课表」接口逐周回查:
- * 以目标课程所在星期的最近一个日期为参照,按周展开探测(区间大约覆盖前后一个学期),
- * 再把每个命中日期当作「该课第一次上课的那天」,用课程自己的周次表去核对:
- * 前一周必须没课,且课程周次表里的每一周都应当对得上。
- * 核对通过后,开学日期 = 首次上课日期 - ((起始周 - 1) * 7 + (星期 - 1)),按周一为一周之始。
- *
- * 校验通过多个候选时取离今天最近的那个,避免把上一学年的同名课程认成本学期。
- * 任何一步拿不到结果就返回 null,由调用方跳过配置保存。
- */
- async function deriveSemesterStartDate(courses, studentId) {
- if (!studentId) {
- console.warn("JS: 未取到学号,跳过开学日期推算。");
- return null;
- }
- const target = pickProbeCourse(courses);
- if (!target) {
- console.warn("JS: 没有可用于探测的课程,跳过开学日期推算。");
- return null;
- }
- const reference = findReferenceDate(target.day);
- const maxBackWeeks = 40;
- const maxForwardWeeks = 12;
- const offsets = [0];
- for (let week = 1; week <= maxBackWeeks; week++) {
- offsets.push(-week);
- if (week <= maxForwardWeeks) offsets.push(week);
- }
- const observed = new Map();
- const batchSize = 2;
- let hitBatchIndex = -1;
- for (let index = 0; index < offsets.length; index += batchSize) {
- const batch = offsets.slice(index, index + batchSize);
- const results = await Promise.all(
- batch.map(async offset => ({
- offset: offset,
- hit: await hasCourseOn(addDays(reference, offset * 7), target.name, studentId)
- }))
- );
- for (const item of results) {
- observed.set(item.offset, item.hit);
- }
- if (hitBatchIndex === -1 && results.some(item => item.hit)) {
- hitBatchIndex = index / batchSize;
- }
- // 找到命中后再多探一批,够核对课程周次就够了,不必把整个区间探完
- if (hitBatchIndex !== -1 && index / batchSize > hitBatchIndex) {
- break;
- }
- // 稍作等待防止风控
- const delay = 300 + Math.floor(Math.random() * 200);
- await new Promise(resolve => setTimeout(resolve, delay));
- }
- const hitOffsets = Array.from(observed.keys())
- .filter(offset => observed.get(offset))
- .sort((a, b) => Math.abs(a) - Math.abs(b) || a - b);
- if (hitOffsets.length === 0) {
- console.warn(`JS: 前后约一个学期内未探到课程「${target.name}」,跳过开学日期推算。`);
- return null;
- }
- const firstWeek = target.weeks[0];
- // 单双周课程每隔一周才上课,用课程自己的周次间隔做「上一次课」的核对距离,
- // 否则会把「课程中途的某一次课」误判成起始周(相邻一周本来就该没课)。
- const weekStep = target.weeks.length > 1 ? target.weeks[1] - target.weeks[0] : 1;
- for (const candidate of hitOffsets) {
- // 起始周之前的那一次课必须没有课,否则这个候选只是课程中途的一次课
- if (observed.get(candidate - weekStep) === true) continue;
- // 用课程自己的周次表核对:课程上课的每一周都应探到课
- let consistent = true;
- let checked = 0;
- for (const week of target.weeks) {
- const offset = candidate + (week - firstWeek);
- const seen = observed.get(offset);
- if (seen === undefined) continue;
- checked += 1;
- if (!seen) {
- consistent = false;
- break;
- }
- }
- if (!consistent || checked < 1) continue;
- const firstClassDate = addDays(reference, candidate * 7);
- const startDate = addDays(firstClassDate, -((firstWeek - 1) * 7 + (target.day - 1)));
- // 推算出的开学日期必须是周一,否则说明数据对不上,宁可不写
- if (startDate.getDay() !== 1) continue;
- console.log(`JS: 探测课程「${target.name}」首次上课日期 ${formatDate(firstClassDate)}(第 ${firstWeek} 周),推算开学日期 ${formatDate(startDate)}。`);
- return formatDate(startDate);
- }
- console.warn(`JS: 课程「${target.name}」的探测结果与周次表对不上,跳过开学日期推算。`);
- return null;
- }
- /**
- * 计算本学期总周数:取所有课程周次的最大值。
- * 研究生系统没有校历,这个值只是课表的周次上限,多算几周无空白无妨,少算会截断课程。
- */
- function calculateTotalWeeks(courses) {
- let maxWeek = 0;
- for (const course of courses) {
- for (const week of course.weeks) {
- if (week > maxWeek) maxWeek = week;
- }
- }
- return maxWeek;
- }
- async function promptUserToStart() {
- return await window.shiguangBridgePromise.showAlert(
- "研究生系统课表导入",
- "导入前请确保您已在浏览器中登录江苏大学研究生管理系统(可从综合服务门户点「研究生管理系统」进入,或直接访问 http://yjsgll.ujs.edu.cn/pyxx/)。",
- "好的,开始导入"
- );
- }
- async function saveCourses(parsedCourses) {
- try {
- await window.shiguangBridgePromise.saveImportedCourses(JSON.stringify(parsedCourses));
- return true;
- } catch (error) {
- window.shiguangBridge.showToast(`课程保存失败: ${error.message}`);
- console.error("JS: Save Courses Error:", error);
- return false;
- }
- }
- /**
- * 只在能拿到真实开学日期时写入课表配置。
- * 应用侧的 saveCourseConfig 是整体覆盖而非字段级合并:没有传入的字段会被写成模型默认值,
- * 其中 semesterStartDate 的默认值是 null,会把用户已经设置好的开学日期清空。
- * 所以拿不到开学日期时返回 null,宁可不写也不要写坏。
- */
- async function saveCourseConfigIfPossible(config) {
- if (!config) {
- window.shiguangBridge.showToast("未取到本学期开学日期,已跳过课表配置,请在应用内手动设置开学日期。");
- console.log("JS: 无可用开学日期,跳过 saveCourseConfig 以保留用户现有配置。");
- return;
- }
- try {
- await window.shiguangBridgePromise.saveCourseConfig(JSON.stringify(config));
- window.shiguangBridge.showToast(
- `课表配置更新成功!开学日期 ${config.semesterStartDate},总周数 ${config.semesterTotalWeeks} 周。`
- );
- } catch (error) {
- window.shiguangBridge.showToast(`课表配置保存失败: ${error.message}`);
- console.error("JS: Save Config Error:", error);
- }
- }
- async function importPresetTimeSlots(timeSlots) {
- if (timeSlots.length === 0) {
- window.shiguangBridge.showToast("警告:时间段为空,未导入时间段信息。");
- return;
- }
- window.shiguangBridge.showToast(`正在导入 ${timeSlots.length} 个预设时间段...`);
- try {
- await window.shiguangBridgePromise.savePresetTimeSlots(JSON.stringify(timeSlots));
- } catch (error) {
- window.shiguangBridge.showToast("导入时间段失败: " + error.message);
- console.error("JS: Save Time Slots Error:", error);
- }
- }
- async function runImportFlow() {
- if (!isGraduateSystemPage()) {
- window.shiguangBridge.showToast("导入失败:请先进入江苏大学研究生管理系统!");
- console.log("JS: 当前不在研究生管理系统页面,终止导入。");
- return;
- }
- if (isLoginPage()) {
- window.shiguangBridge.showToast("导入失败:请先登录研究生管理系统!");
- console.log("JS: 检测到当前在登录页面,终止导入。");
- return;
- }
- const alertConfirmed = await promptUserToStart();
- if (!alertConfirmed) {
- window.shiguangBridge.showToast("用户取消了导入。");
- return;
- }
- window.shiguangBridge.showToast("正在读取课表数据...");
- // 先取一次默认(当前)学期,用于学期列表与课表
- let defaultDoc;
- try {
- defaultDoc = await fetchTimetableDocument("");
- } catch (error) {
- if (error.message === "NOT_LOGGED_IN") {
- window.shiguangBridge.showToast("未登录或登录已过期,请先登录研究生管理系统。");
- } else {
- window.shiguangBridge.showToast(`读取课表失败: ${error.message}`);
- }
- console.error("JS: 读取课表页失败:", error);
- return;
- }
- const semesterData = readSemesterOptions(defaultDoc);
- if (!semesterData) {
- window.shiguangBridge.showToast("未能读取学期列表,请确认已登录并停留在研究生管理系统。");
- return;
- }
- const semesterIndex = await window.shiguangBridgePromise.showSingleSelection(
- "选择学期",
- JSON.stringify(semesterData.options.map(item => item.text)),
- semesterData.defaultIndex
- );
- if (semesterIndex === null || semesterIndex === -1) {
- window.shiguangBridge.showToast("导入已取消。");
- return;
- }
- const semesterValue = semesterData.options[semesterIndex].value;
- const semesterText = semesterData.options[semesterIndex].text;
- let timetableDoc = defaultDoc;
- if (semesterIndex !== semesterData.defaultIndex) {
- window.shiguangBridge.showToast(`正在切换到 ${semesterText} ...`);
- try {
- timetableDoc = await fetchTimetableDocument(semesterValue);
- } catch (error) {
- window.shiguangBridge.showToast(`切换学期失败: ${error.message}`);
- console.error("JS: 切换学期失败:", error);
- return;
- }
- }
- const courses = parseCoursesFromGrid(timetableDoc);
- if (courses.length === 0) {
- window.shiguangBridge.showToast(`${semesterText} 未找到任何课程,请确认所选学期是否正确。`);
- console.log("JS: 课表为空,终止导入。");
- return;
- }
- const timeSlots = parseTimeSlots(timetableDoc);
- console.log(`JS: 解析到 ${courses.length} 门课程,${timeSlots.length} 个时间段。`);
- // 学号取自课表页隐藏域,用来查「我的课表」接口
- const studentId = (timetableDoc.querySelector("#MainWork_WUCpyjhdy_HFfilename") || {}).value || "";
- window.shiguangBridge.showToast("正在推算开学日期...");
- const semesterStartDate = await deriveSemesterStartDate(courses, studentId);
- const totalWeeks = calculateTotalWeeks(courses);
- const config = semesterStartDate
- ? {
- semesterStartDate: semesterStartDate,
- semesterTotalWeeks: totalWeeks,
- firstDayOfWeek: 1
- }
- : null;
- const saveResult = await saveCourses(courses);
- if (!saveResult) {
- console.log("JS: 课程保存失败,流程终止。");
- return;
- }
- await saveCourseConfigIfPossible(config);
- await importPresetTimeSlots(timeSlots);
- await window.shiguangBridgePromise.showAlert(
- "导入完成",
- `已导入 ${semesterText} 共 ${courses.length} 门课程。\n\n` +
- `作息时间取自研究生系统课表页当前显示的作息(全天 ${timeSlots.length} 节),` +
- "学校切换夏令时/冬令时后请重新导入,或在应用内手动修改时间段。\n\n" +
- (semesterStartDate
- ? `开学日期由课表数据反推得到:${semesterStartDate},请在应用内核对。`
- : "未能反推开学日期,请在应用内手动设置。"),
- "我知道了"
- );
- window.shiguangBridge.showToast(`课程导入成功,共导入 ${courses.length} 门课程!`);
- console.log("JS: 整个导入流程执行完毕并成功。");
- window.shiguangBridge.notifyTaskCompletion();
- }
- runImportFlow();
|