|
|
@@ -0,0 +1,784 @@
|
|
|
+// 四川托普信息技术职业学院 · 老版正方教务课表导入
|
|
|
+//
|
|
|
+// 一次导入会读取三个页面并合并:
|
|
|
+// 1. 班级课表查询 tjkbcx.aspx(必修类以此为准;格子格式「1-14(1,2)」)
|
|
|
+// 2. 学生个人课表 xskbcx.aspx(选修课以此为准;格子格式「周一第1,2节{第1-17周}」)
|
|
|
+// 3. 学生选课情况查询 xsxkqk.aspx(只用来读「课程性质」,区分选修/必修)
|
|
|
+// 合并规则:选修课以个人课表为准;必修类只要班级课表里有就以班级课表为准
|
|
|
+// (班级课表调课会及时更新,个人课表是学期初的快照),班级课表完全没有的必修课
|
|
|
+// 才用个人课表兜底。
|
|
|
+// 从任意一个课表页点导入都可以,脚本会自己去读另一个页面。
|
|
|
+//
|
|
|
+// 作息取自学校作息时间表:A 教学区的第 3/4 节与其它教学区不同,周五下午整体提前半小时。
|
|
|
+// 没有固定上课时间的课程(实践、实训类)不导入,只在提示里告知数量。
|
|
|
+
|
|
|
+(function () {
|
|
|
+ 'use strict';
|
|
|
+
|
|
|
+ var DAY_BY_CHAR = { '一': 1, '二': 2, '三': 3, '四': 4, '五': 5, '六': 6, '日': 7, '天': 7 };
|
|
|
+
|
|
|
+ // ---------------- 文本处理 ----------------
|
|
|
+
|
|
|
+ function decodeEntities(s) {
|
|
|
+ return String(s)
|
|
|
+ .replace(/ /gi, ' ')
|
|
|
+ .replace(/&/gi, '&')
|
|
|
+ .replace(/</gi, '<')
|
|
|
+ .replace(/>/gi, '>')
|
|
|
+ .replace(/'/g, "'")
|
|
|
+ .replace(/"/gi, '"')
|
|
|
+ .replace(/&#(\d+);/g, function (_, n) { return String.fromCodePoint(Number(n)); });
|
|
|
+ }
|
|
|
+
|
|
|
+ function cellText(html) {
|
|
|
+ return decodeEntities(String(html).replace(/<br\s*\/?>/gi, '\n').replace(/<[^>]+>/g, ''));
|
|
|
+ }
|
|
|
+
|
|
|
+ function normalize(raw) {
|
|
|
+ return String(raw)
|
|
|
+ .replace(/[((]/g, '(').replace(/[))]/g, ')')
|
|
|
+ .replace(/[,、;;]/g, ',')
|
|
|
+ .replace(/[-–—]/g, '-')
|
|
|
+ .replace(/[0-9]/g, function (d) { return String.fromCharCode(d.charCodeAt(0) - 65248); })
|
|
|
+ .replace(/\s+/g, '');
|
|
|
+ }
|
|
|
+
|
|
|
+ // ---------------- 作息时间(学校作息表) ----------------
|
|
|
+
|
|
|
+ var TIME_SLOTS_DEFAULT = [
|
|
|
+ { number: 1, startTime: '08:30', endTime: '09:15' },
|
|
|
+ { number: 2, startTime: '09:20', endTime: '10:05' },
|
|
|
+ { number: 3, startTime: '10:20', endTime: '11:05' },
|
|
|
+ { number: 4, startTime: '11:10', endTime: '11:55' },
|
|
|
+ { number: 5, startTime: '14:00', endTime: '14:45' },
|
|
|
+ { number: 6, startTime: '14:50', endTime: '15:35' },
|
|
|
+ { number: 7, startTime: '15:50', endTime: '16:35' },
|
|
|
+ { number: 8, startTime: '16:40', endTime: '17:25' },
|
|
|
+ { number: 9, startTime: '18:30', endTime: '19:15' },
|
|
|
+ { number: 10, startTime: '19:20', endTime: '20:05' }
|
|
|
+ ];
|
|
|
+
|
|
|
+ // A 教学区以外的第 3/4 节晚 20 分钟
|
|
|
+ var TIME_SLOTS_OUTSIDE_A = TIME_SLOTS_DEFAULT.map(function (slot) {
|
|
|
+ if (slot.number === 3) return { number: 3, startTime: '10:40', endTime: '11:25' };
|
|
|
+ if (slot.number === 4) return { number: 4, startTime: '11:30', endTime: '12:15' };
|
|
|
+ return slot;
|
|
|
+ });
|
|
|
+
|
|
|
+ // 周五下午整体提前半小时(学校作息表只对第 5-8 节做了说明)
|
|
|
+ var TIME_SLOTS_FRIDAY_PM = {
|
|
|
+ 5: { startTime: '13:30', endTime: '14:15' },
|
|
|
+ 6: { startTime: '14:20', endTime: '15:05' },
|
|
|
+ 7: { startTime: '15:20', endTime: '16:05' },
|
|
|
+ 8: { startTime: '16:10', endTime: '16:55' }
|
|
|
+ };
|
|
|
+
|
|
|
+ function slotOf(table, section) {
|
|
|
+ return table[section - 1] || null;
|
|
|
+ }
|
|
|
+
|
|
|
+ function isAreaA(position) {
|
|
|
+ var p = String(position || '').trim();
|
|
|
+ if (!p) return true; // 地点缺失时按默认作息处理
|
|
|
+ return /^A\d/i.test(p) || /^A[-区]/i.test(p);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 周五只在这门课整段都落在第 5-8 节时才提前半小时;
|
|
|
+ // 跨到晚上(第 9、10 节)的课整体按默认作息处理,避免拼出不存在的时段。
|
|
|
+ function useFridayAfternoon(course) {
|
|
|
+ return (
|
|
|
+ course.day === 5 &&
|
|
|
+ course.startSection >= 5 &&
|
|
|
+ course.endSection <= 8 &&
|
|
|
+ Boolean(TIME_SLOTS_FRIDAY_PM[course.startSection]) &&
|
|
|
+ Boolean(TIME_SLOTS_FRIDAY_PM[course.endSection])
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ // ---------------- 周次 / 节次解析 ----------------
|
|
|
+
|
|
|
+ function parseWeekList(str) {
|
|
|
+ var weeks = [];
|
|
|
+ str.split(',').forEach(function (seg) {
|
|
|
+ var m = seg.match(/^(\d+)(?:[-~](\d+))?(单|双)?$/);
|
|
|
+ if (!m) return;
|
|
|
+ var from = parseInt(m[1], 10);
|
|
|
+ var to = m[2] ? parseInt(m[2], 10) : from;
|
|
|
+ var flag = m[3] || '';
|
|
|
+ if (!(from > 0) || to < from) return;
|
|
|
+ for (var w = from; w <= to; w++) {
|
|
|
+ if (flag === '单' && w % 2 === 0) continue;
|
|
|
+ if (flag === '双' && w % 2 === 1) continue;
|
|
|
+ if (weeks.indexOf(w) === -1) weeks.push(w);
|
|
|
+ }
|
|
|
+ });
|
|
|
+ return weeks.sort(function (a, b) { return a - b; });
|
|
|
+ }
|
|
|
+
|
|
|
+ function parseSectionList(str) {
|
|
|
+ var out = [];
|
|
|
+ str.split(',').forEach(function (seg) {
|
|
|
+ var m = seg.match(/^(\d+)(?:[-~](\d+))?$/);
|
|
|
+ if (!m) return;
|
|
|
+ var from = parseInt(m[1], 10);
|
|
|
+ var to = m[2] ? parseInt(m[2], 10) : from;
|
|
|
+ if (!(from > 0) || to < from) return;
|
|
|
+ for (var s = from; s <= to; s++) if (out.indexOf(s) === -1) out.push(s);
|
|
|
+ });
|
|
|
+ return out.sort(function (a, b) { return a - b; });
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 解析课程格里的「周次(节次)」那一行,兼容两个页面各自的格式:
|
|
|
+ * 学生个人课表:周一第1,2节{第1-17周} / 周二第1,2节{第2-16周|双周}
|
|
|
+ * 班级课表: 1-14(1,2) / 2-14双(1,2) / 18(1,2) / 1-8,10-16(1,2)
|
|
|
+ * day 为 0 表示这一行没写星期几,星期由所在列决定。
|
|
|
+ */
|
|
|
+ function parseInfoLine(raw) {
|
|
|
+ var text = normalize(raw);
|
|
|
+
|
|
|
+ var student = text.match(/^周([一二三四五六日天])第([\d,\-~]+)节\{第([\d,\-~]+)周(?:\|(单|双)周?)?\}$/);
|
|
|
+ if (student) {
|
|
|
+ var weeks = parseWeekList(student[3] + (student[4] || ''));
|
|
|
+ var sections = parseSectionList(student[2]);
|
|
|
+ if (weeks.length && sections.length) {
|
|
|
+ return { day: DAY_BY_CHAR[student[1]], weeks: weeks, sections: sections };
|
|
|
+ }
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ var classTable = text.match(/^([\d,\-~单双]+)\(([\d,\-~]+)\)$/);
|
|
|
+ if (classTable) {
|
|
|
+ var weeks2 = parseWeekList(classTable[1]);
|
|
|
+ var sections2 = parseSectionList(classTable[2]);
|
|
|
+ if (weeks2.length && sections2.length) {
|
|
|
+ return { day: 0, weeks: weeks2, sections: sections2 };
|
|
|
+ }
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 一个格子里可能排了多门课,用空行分隔(班级课表里用 <br><br><br> 分隔)。
|
|
|
+ * 以「周次(节次)」那一行为锚点:上一行是课程名,下面两行是教师和地点。
|
|
|
+ */
|
|
|
+ function parseCellBlocks(text, stats) {
|
|
|
+ var lines = String(text).split('\n')
|
|
|
+ .map(function (line) { return line.replace(/\s+/g, ' ').trim(); })
|
|
|
+ .filter(Boolean);
|
|
|
+
|
|
|
+ var blocks = [];
|
|
|
+ for (var i = 0; i < lines.length; i++) {
|
|
|
+ var info = parseInfoLine(lines[i]);
|
|
|
+ if (!info) continue;
|
|
|
+ var name = lines[i - 1] || '';
|
|
|
+ if (!name || parseInfoLine(name)) continue;
|
|
|
+ var teacher = lines[i + 1] || '';
|
|
|
+ var position = lines[i + 2] || '';
|
|
|
+ if (parseInfoLine(teacher)) teacher = '';
|
|
|
+ if (parseInfoLine(position)) position = '';
|
|
|
+ if (!teacher || !position) stats.incomplete++;
|
|
|
+ blocks.push({
|
|
|
+ name: name,
|
|
|
+ teacher: teacher,
|
|
|
+ position: position,
|
|
|
+ day: info.day,
|
|
|
+ weeks: info.weeks,
|
|
|
+ sections: info.sections
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!blocks.length && lines.length) {
|
|
|
+ stats.unrecognized.push(lines.join(' / ').slice(0, 80));
|
|
|
+ }
|
|
|
+ return blocks;
|
|
|
+ }
|
|
|
+
|
|
|
+ // ---------------- 表格解析(对任意 Document 生效) ----------------
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 把表格展开成网格,处理 rowspan / colspan,拿到每个格子的真实列号。
|
|
|
+ * 必须用 table.rows / row.cells:用 querySelectorAll('td') 会把嵌套表格里的
|
|
|
+ * 格子也算进来(页面里确实存在外层表格套课表的写法),导致列号整体错位。
|
|
|
+ */
|
|
|
+ function buildGrid(table) {
|
|
|
+ var rows = [].slice.call(table.rows || table.querySelectorAll('tr'));
|
|
|
+ var grid = [];
|
|
|
+ var occupied = [];
|
|
|
+
|
|
|
+ for (var r = 0; r < rows.length; r++) {
|
|
|
+ grid[r] = grid[r] || [];
|
|
|
+ occupied[r] = occupied[r] || [];
|
|
|
+ var cells = [].slice.call(rows[r].cells || rows[r].querySelectorAll('td,th'));
|
|
|
+ var column = 0;
|
|
|
+
|
|
|
+ for (var i = 0; i < cells.length; i++) {
|
|
|
+ while (occupied[r][column]) column++;
|
|
|
+ var cell = cells[i];
|
|
|
+ var colSpan = parseInt(cell.getAttribute('colspan') || '1', 10) || 1;
|
|
|
+ var rowSpan = parseInt(cell.getAttribute('rowspan') || '1', 10) || 1;
|
|
|
+
|
|
|
+ for (var rr = r; rr < r + rowSpan; rr++) {
|
|
|
+ occupied[rr] = occupied[rr] || [];
|
|
|
+ for (var cc = column; cc < column + colSpan; cc++) {
|
|
|
+ occupied[rr][cc] = true;
|
|
|
+ if (rr === r && cc === column) grid[rr][cc] = cell; // 合并格只认左上角
|
|
|
+ }
|
|
|
+ }
|
|
|
+ column += colSpan;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return grid;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 找到写有「星期一…星期日」的表头行,并建立 列号 -> 星期 的对应关系。
|
|
|
+ * 表头不一定在第一行(上面可能有标题行),所以往下找几行。
|
|
|
+ */
|
|
|
+ function findHeader(grid) {
|
|
|
+ for (var r = 0; r < Math.min(grid.length, 6); r++) {
|
|
|
+ var dayByColumn = {};
|
|
|
+ for (var c = 0; c < (grid[r] || []).length; c++) {
|
|
|
+ var cell = grid[r][c];
|
|
|
+ if (!cell) continue;
|
|
|
+ var text = cellText(cell.innerHTML).replace(/\s+/g, '');
|
|
|
+ var m = text.match(/^星期([一二三四五六日天])$/);
|
|
|
+ if (m) dayByColumn[c] = DAY_BY_CHAR[m[1]];
|
|
|
+ }
|
|
|
+ if (Object.keys(dayByColumn).length >= 5) return { row: r, dayByColumn: dayByColumn };
|
|
|
+ }
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 在指定文档里依次尝试候选表格,取第一个能找到周课表的 */
|
|
|
+ function findCourseTable(doc) {
|
|
|
+ var candidates = [];
|
|
|
+ ['Table6', 'Table1'].forEach(function (id) {
|
|
|
+ var el = doc.getElementById(id);
|
|
|
+ if (el && el.tagName && el.tagName.toLowerCase() === 'table') candidates.push(el);
|
|
|
+ });
|
|
|
+ [].slice.call(doc.querySelectorAll('table.blacktab')).forEach(function (t) {
|
|
|
+ if (candidates.indexOf(t) === -1) candidates.push(t);
|
|
|
+ });
|
|
|
+
|
|
|
+ for (var i = 0; i < candidates.length; i++) {
|
|
|
+ var grid = buildGrid(candidates[i]);
|
|
|
+ var header = findHeader(grid);
|
|
|
+ if (header) return { grid: grid, header: header };
|
|
|
+ }
|
|
|
+
|
|
|
+ var tables = [].slice.call(doc.querySelectorAll('table'));
|
|
|
+ for (var j = 0; j < tables.length; j++) {
|
|
|
+ if (candidates.indexOf(tables[j]) !== -1) continue;
|
|
|
+ var g = buildGrid(tables[j]);
|
|
|
+ var h = findHeader(g);
|
|
|
+ if (h) return { grid: g, header: h };
|
|
|
+ }
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 统计「实践课(或无上课时间)」「未安排上课时间的课程」有多少条,只用于提示 */
|
|
|
+ function countCoursesWithoutTime(doc) {
|
|
|
+ var count = 0;
|
|
|
+ ['DataGrid1', 'Datagrid2'].forEach(function (id) {
|
|
|
+ var table = doc.getElementById(id);
|
|
|
+ if (!table || !table.rows) return;
|
|
|
+ for (var r = 1; r < table.rows.length; r++) {
|
|
|
+ if (String(table.rows[r].textContent || '').trim()) count++;
|
|
|
+ }
|
|
|
+ });
|
|
|
+ return count;
|
|
|
+ }
|
|
|
+
|
|
|
+ // ---------------- 课程性质(选修/必修)----------------
|
|
|
+ // 课表页里没有「课程性质」,要去「学生选课情况查询」页取。
|
|
|
+ var COURSE_TYPE_PATH = 'xsxkqk.aspx?gnmkdm=N121615';
|
|
|
+
|
|
|
+ function normalizeCourseName(name) {
|
|
|
+ return String(name || '').replace(/\s+/g, '').replace(/(/g, '(').replace(/)/g, ')');
|
|
|
+ }
|
|
|
+
|
|
|
+ function isElectiveType(type) {
|
|
|
+ return /选修/.test(String(type || ''));
|
|
|
+ }
|
|
|
+
|
|
|
+ function findCourseTypeTable(doc) {
|
|
|
+ var tables = [].slice.call(doc.querySelectorAll('table'));
|
|
|
+ for (var i = 0; i < tables.length; i++) {
|
|
|
+ var head = tables[i].rows && tables[i].rows[0];
|
|
|
+ if (head && /课程性质/.test(String(head.textContent || ''))) return tables[i];
|
|
|
+ }
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 从「学生选课情况查询」页读出 课程名称 -> 课程性质 */
|
|
|
+ function parseCourseTypes(doc) {
|
|
|
+ var map = {};
|
|
|
+ var table = findCourseTypeTable(doc);
|
|
|
+ if (!table || !table.rows) return map;
|
|
|
+ for (var r = 0; r < table.rows.length; r++) {
|
|
|
+ var cells = table.rows[r].cells;
|
|
|
+ if (!cells || cells.length < 3) continue;
|
|
|
+ var name = String(cells[1].textContent || '').trim();
|
|
|
+ var type = String(cells[2].textContent || '').trim();
|
|
|
+ if (!name || !type || name === '课程名称') continue;
|
|
|
+ map[normalizeCourseName(name)] = type;
|
|
|
+ }
|
|
|
+ return map;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 读取「学生选课情况查询」页;学期与当前页不一致时按它自己的表单回发一次 */
|
|
|
+ async function loadCourseTypes(studentId, liveTerm) {
|
|
|
+ var url = String(window.location.origin || '') + '/' + COURSE_TYPE_PATH + '&xh=' + encodeURIComponent(studentId);
|
|
|
+ var doc = await fetchDocument(url);
|
|
|
+
|
|
|
+ var yearSelect = doc.querySelector('select[name="ddlXN"]');
|
|
|
+ var termSelect = doc.querySelector('select[name="ddlXQ"]');
|
|
|
+ if (liveTerm && yearSelect && termSelect &&
|
|
|
+ (yearSelect.value !== liveTerm.year || termSelect.value !== liveTerm.term)) {
|
|
|
+ var rerendered = await repostWithTerm(doc, url, {
|
|
|
+ yearField: yearSelect.name, year: liveTerm.year,
|
|
|
+ termField: termSelect.name, term: liveTerm.term
|
|
|
+ });
|
|
|
+ if (rerendered) doc = rerendered;
|
|
|
+ }
|
|
|
+ return parseCourseTypes(doc);
|
|
|
+ }
|
|
|
+
|
|
|
+ function collectCourses(doc, stats) {
|
|
|
+ var found = findCourseTable(doc);
|
|
|
+ if (!found) return [];
|
|
|
+ var grid = found.grid;
|
|
|
+ var courses = [];
|
|
|
+
|
|
|
+ for (var r = found.header.row + 1; r < grid.length; r++) {
|
|
|
+ for (var c = 0; c < (grid[r] || []).length; c++) {
|
|
|
+ var columnDay = found.header.dayByColumn[c];
|
|
|
+ var cell = grid[r][c];
|
|
|
+ if (!columnDay || !cell) continue;
|
|
|
+
|
|
|
+ parseCellBlocks(cellText(cell.innerHTML), stats).forEach(function (block) {
|
|
|
+ // 学生个人课表的周次行自带「周一…」,以它为准(合并格会让后面的行少几个格子);
|
|
|
+ // 班级课表没有写星期几,用表头列对应。
|
|
|
+ if (block.day && block.day !== columnDay) stats.dayMismatch++;
|
|
|
+ courses.push({
|
|
|
+ name: block.name,
|
|
|
+ teacher: block.teacher,
|
|
|
+ position: block.position,
|
|
|
+ day: block.day || columnDay,
|
|
|
+ weeks: block.weeks,
|
|
|
+ startSection: block.sections[0],
|
|
|
+ endSection: block.sections[block.sections.length - 1]
|
|
|
+ });
|
|
|
+ });
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 同一门课、同一天、同节次、同地点、同周次只保留一条
|
|
|
+ var seen = {};
|
|
|
+ return courses.filter(function (course) {
|
|
|
+ var key = [
|
|
|
+ course.name, course.day, course.teacher, course.position,
|
|
|
+ course.startSection, course.endSection, course.weeks.join(',')
|
|
|
+ ].join('|');
|
|
|
+ if (seen[key]) return false;
|
|
|
+ seen[key] = true;
|
|
|
+ return true;
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 把与默认作息不同的课写成自定义时间,避免整份作息被搞乱 */
|
|
|
+ function applyCustomTimes(courses, stats) {
|
|
|
+ return courses.map(function (course) {
|
|
|
+ var table = isAreaA(course.position) ? TIME_SLOTS_DEFAULT : TIME_SLOTS_OUTSIDE_A;
|
|
|
+ var startSlot = slotOf(table, course.startSection);
|
|
|
+ var endSlot = slotOf(table, course.endSection);
|
|
|
+ var defaultStart = slotOf(TIME_SLOTS_DEFAULT, course.startSection);
|
|
|
+ var defaultEnd = slotOf(TIME_SLOTS_DEFAULT, course.endSection);
|
|
|
+
|
|
|
+ if (!startSlot || !endSlot || !defaultStart || !defaultEnd) {
|
|
|
+ stats.sectionOverflow++;
|
|
|
+ return course;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (useFridayAfternoon(course)) {
|
|
|
+ var fridayStart = TIME_SLOTS_FRIDAY_PM[course.startSection];
|
|
|
+ var fridayEnd = TIME_SLOTS_FRIDAY_PM[course.endSection];
|
|
|
+ return Object.assign({}, course, {
|
|
|
+ isCustomTime: true,
|
|
|
+ customStartTime: fridayStart.startTime,
|
|
|
+ customEndTime: fridayEnd.endTime
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ if (startSlot.startTime === defaultStart.startTime && endSlot.endTime === defaultEnd.endTime) {
|
|
|
+ return course; // 与默认作息一致,不用单独设置
|
|
|
+ }
|
|
|
+
|
|
|
+ return Object.assign({}, course, {
|
|
|
+ isCustomTime: true,
|
|
|
+ customStartTime: startSlot.startTime,
|
|
|
+ customEndTime: endSlot.endTime
|
|
|
+ });
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ // ---------------- 两个课表页的取数 ----------------
|
|
|
+
|
|
|
+ function pickStudentId(text) {
|
|
|
+ var m = String(text || '').match(/[?&]xh=([0-9A-Za-z]+)/);
|
|
|
+ return m ? m[1] : '';
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 学号可能出现在:当前地址、页面文字(「学号:xxx」)、左侧菜单里的课表链接。
|
|
|
+ * 老正方的课表是框架页,脚本有可能跑在顶层框架上,那里没有课表也没有学号文字,
|
|
|
+ * 所以同源的其它框架也要扫一遍。
|
|
|
+ */
|
|
|
+ function getStudentId() {
|
|
|
+ var found = pickStudentId(window.location.search) || pickStudentId(window.location.href);
|
|
|
+ if (found) return found;
|
|
|
+
|
|
|
+ var windows = [window];
|
|
|
+ try { if (window.top && window.top !== window) windows.push(window.top); } catch (e) {}
|
|
|
+ try { if (window.parent && window.parent !== window) windows.push(window.parent); } catch (e) {}
|
|
|
+
|
|
|
+ for (var i = 0; i < windows.length && !found; i++) {
|
|
|
+ var doc = null;
|
|
|
+ try { doc = windows[i].document; } catch (e) { doc = null; }
|
|
|
+ if (!doc) continue;
|
|
|
+
|
|
|
+ var text = doc.body ? String(doc.body.textContent || '') : '';
|
|
|
+ var m = text.match(/学号[::]\s*([0-9A-Za-z]+)/);
|
|
|
+ if (m) { found = m[1]; break; }
|
|
|
+
|
|
|
+ var links = doc.querySelectorAll('a[href*="xh="]');
|
|
|
+ for (var j = 0; j < links.length; j++) {
|
|
|
+ found = pickStudentId(links[j].getAttribute('href') || '');
|
|
|
+ if (found) break;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return found || '';
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 从页面里读出当前选中的学年 / 学期(两个页面的控件名分别是 xnd/xqd 与 xn/xq) */
|
|
|
+ function readTerm(doc) {
|
|
|
+ var yearSelect = doc.querySelector('select[name="xnd"], select[name="xn"]');
|
|
|
+ var termSelect = doc.querySelector('select[name="xqd"], select[name="xq"]');
|
|
|
+ if (!yearSelect || !termSelect) return null;
|
|
|
+ return {
|
|
|
+ yearField: yearSelect.name, year: yearSelect.value,
|
|
|
+ termField: termSelect.name, term: termSelect.value
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 教务页面是 GB2312,而 response.text() 在部分 WebView 里一律按 UTF-8 解码,
|
|
|
+ * 中文会变成乱码、一条课程都解析不出来(表现为 HTTP 200、字节数正常、读到 0 门)。
|
|
|
+ * 所以自己拿字节解码:两种解都试,取含中文关键词多的那个。
|
|
|
+ */
|
|
|
+ function decodeBytes(buffer) {
|
|
|
+ function tryDecode(label) {
|
|
|
+ try { return new TextDecoder(label).decode(buffer); } catch (e) { return ''; }
|
|
|
+ }
|
|
|
+ function score(text) {
|
|
|
+ if (!text) return -1;
|
|
|
+ var hits = 0;
|
|
|
+ ['星期', '课程', '节', '周', '教师', '教室', '学号'].forEach(function (kw) {
|
|
|
+ var at = text.indexOf(kw);
|
|
|
+ while (at !== -1 && hits < 60) { hits++; at = text.indexOf(kw, at + 1); }
|
|
|
+ });
|
|
|
+ return hits - (text.split('\uFFFD').length - 1) * 3; // 乱码字符要扣分
|
|
|
+ }
|
|
|
+ var gbk = tryDecode('gbk');
|
|
|
+ var utf8 = tryDecode('utf-8');
|
|
|
+ return score(gbk) >= score(utf8) ? gbk : utf8;
|
|
|
+ }
|
|
|
+
|
|
|
+ async function readResponseText(response) {
|
|
|
+ try {
|
|
|
+ var buffer = await response.arrayBuffer();
|
|
|
+ return decodeBytes(buffer);
|
|
|
+ } catch (error) {
|
|
|
+ return await response.text(); // 老 WebView 不支持 arrayBuffer 时退回原样
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ async function fetchDocument(url) {
|
|
|
+ var response = await fetch(url, { credentials: 'include' });
|
|
|
+ var html = await readResponseText(response);
|
|
|
+ var doc = new DOMParser().parseFromString(html, 'text/html');
|
|
|
+ doc.__fetchInfo = {
|
|
|
+ status: (response && response.status) || 0,
|
|
|
+ length: html.length,
|
|
|
+ title: String(doc.title || '').slice(0, 24)
|
|
|
+ };
|
|
|
+ if (response && response.ok === false) throw new Error('HTTP ' + response.status);
|
|
|
+ return doc;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 页面上的学年/学期是下拉框,改了要回发一次才会重排;这里按页面自己的表单字段回发 */
|
|
|
+ async function repostWithTerm(doc, url, term) {
|
|
|
+ var form = doc.querySelector('form');
|
|
|
+ if (!form) return null;
|
|
|
+
|
|
|
+ var pairs = [];
|
|
|
+ [].slice.call(form.querySelectorAll('input[type="hidden"]')).forEach(function (input) {
|
|
|
+ if (input.name) pairs.push([input.name, input.value || '']);
|
|
|
+ });
|
|
|
+ [].slice.call(form.querySelectorAll('select[name]')).forEach(function (select) {
|
|
|
+ pairs.push([select.name, select.value || '']);
|
|
|
+ });
|
|
|
+
|
|
|
+ function setPair(key, value) {
|
|
|
+ for (var i = 0; i < pairs.length; i++) {
|
|
|
+ if (pairs[i][0] === key) { pairs[i][1] = value; return; }
|
|
|
+ }
|
|
|
+ pairs.push([key, value]);
|
|
|
+ }
|
|
|
+ setPair(term.yearField, term.year);
|
|
|
+ setPair(term.termField, term.term);
|
|
|
+ setPair('__EVENTTARGET', term.yearField);
|
|
|
+ setPair('__EVENTARGUMENT', '');
|
|
|
+
|
|
|
+ var body = pairs.map(function (pair) {
|
|
|
+ return encodeURIComponent(pair[0]) + '=' + encodeURIComponent(pair[1]);
|
|
|
+ }).join('&');
|
|
|
+
|
|
|
+ var response = await fetch(url, {
|
|
|
+ method: 'POST',
|
|
|
+ credentials: 'include',
|
|
|
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
|
+ body: body
|
|
|
+ });
|
|
|
+ if (response && response.ok === false) throw new Error('HTTP ' + response.status);
|
|
|
+ return new DOMParser().parseFromString(await readResponseText(response), 'text/html');
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 读取一个课表页:先直接取;当前页选的学期和它不一致时再回发一次 */
|
|
|
+ async function loadTimetable(kind, studentId, liveTerm) {
|
|
|
+ var path = (kind === 'student' ? 'xskbcx.aspx' : 'tjkbcx.aspx') + '?xh=' + encodeURIComponent(studentId);
|
|
|
+ var url = String(window.location.origin || '') + '/' + path;
|
|
|
+ var doc = await fetchDocument(url);
|
|
|
+
|
|
|
+ var pageTerm = readTerm(doc);
|
|
|
+ if (liveTerm && pageTerm && (pageTerm.year !== liveTerm.year || pageTerm.term !== liveTerm.term)) {
|
|
|
+ var rerendered = await repostWithTerm(doc, url, {
|
|
|
+ yearField: pageTerm.yearField, year: liveTerm.year,
|
|
|
+ termField: pageTerm.termField, term: liveTerm.term
|
|
|
+ });
|
|
|
+ if (rerendered) doc = rerendered;
|
|
|
+ }
|
|
|
+ return doc;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 合并规则(2026-09-29 用户拍板):
|
|
|
+ * 选修课 → 以个人课表为准(班级课表里属于选修课的条目不采用)
|
|
|
+ * 必修类 → 只要班级课表里有这门课就以班级课表为准;只有班级课表完全没有这门课时,
|
|
|
+ * 才用个人课表的版本兜底
|
|
|
+ * 原因:班级课表调课会及时更新;个人课表是学期初的快照,调课之后不会更新。
|
|
|
+ */
|
|
|
+ function mergeCourses(personal, classCourses, stats, typeMap) {
|
|
|
+ function typeOf(course) { return typeMap ? (typeMap[normalizeCourseName(course.name)] || '') : ''; }
|
|
|
+ function elective(course) { return isElectiveType(typeOf(course)); }
|
|
|
+
|
|
|
+ var classNames = {};
|
|
|
+ classCourses.forEach(function (course) { classNames[normalizeCourseName(course.name)] = true; });
|
|
|
+
|
|
|
+ var result = [];
|
|
|
+
|
|
|
+ classCourses.forEach(function (course) {
|
|
|
+ if (elective(course)) { stats.classElectiveSkipped++; return; }
|
|
|
+ result.push(course);
|
|
|
+ });
|
|
|
+ stats.requiredFromClass = result.length;
|
|
|
+
|
|
|
+ personal.forEach(function (course) {
|
|
|
+ var key = normalizeCourseName(course.name);
|
|
|
+ if (elective(course)) { result.push(course); stats.electiveFromPersonal++; return; }
|
|
|
+ if (classNames[key]) { stats.requiredSkippedPersonal++; return; }
|
|
|
+ result.push(course);
|
|
|
+ stats.requiredFromPersonal++;
|
|
|
+ });
|
|
|
+
|
|
|
+ return result;
|
|
|
+ }
|
|
|
+
|
|
|
+ // ---------------- 主流程 ----------------
|
|
|
+
|
|
|
+ async function runImportFlow() {
|
|
|
+ var confirmed = await window.shiguangBridgePromise.showAlert(
|
|
|
+ '四川托普课表导入',
|
|
|
+ '请先登录教务系统,打开「学生个人课表」或「班级课表查询」中的任意一个,' +
|
|
|
+ '等页面上显示出周课表(能看到「星期一」到「星期日」)再点下面的按钮。\n\n' +
|
|
|
+ '导入会自动读取两个课表页:学生个人课表优先,班级课表只用来补齐个人课表里没有的课程;' +
|
|
|
+ '开学初期个人课表还没排出来时,就完全按班级课表导入。\n\n' +
|
|
|
+ '导入只读取课表,不会修改教务系统里的任何数据。',
|
|
|
+ '开始导入'
|
|
|
+ );
|
|
|
+ if (!confirmed) {
|
|
|
+ window.shiguangBridge.showToast('已取消导入。');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ var studentId = getStudentId();
|
|
|
+ if (!studentId) {
|
|
|
+ await window.shiguangBridgePromise.showAlert(
|
|
|
+ '导入失败',
|
|
|
+ '没有识别出学号。\n请从教务系统里的「学生个人课表」或「班级课表查询」页面点导入。\n\n' +
|
|
|
+ '(诊断:当前地址 ' + String(window.location.href || '').slice(0, 110) + ')',
|
|
|
+ '确定'
|
|
|
+ );
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ var liveTerm = readTerm(document);
|
|
|
+ var stats = {
|
|
|
+ unrecognized: [], incomplete: 0, dayMismatch: 0, sectionOverflow: 0,
|
|
|
+ requiredFromClass: 0, electiveFromPersonal: 0, requiredFromPersonal: 0,
|
|
|
+ classElectiveSkipped: 0, requiredSkippedPersonal: 0,
|
|
|
+ typeMap: {}, typeLoaded: false, failedPages: []
|
|
|
+ };
|
|
|
+
|
|
|
+ var pageInfo = [];
|
|
|
+ var loadOne = async function (kind, label) {
|
|
|
+ try {
|
|
|
+ var doc = await loadTimetable(kind, studentId, liveTerm);
|
|
|
+ var courses = collectCourses(doc, stats);
|
|
|
+ var info = doc.__fetchInfo || {};
|
|
|
+ pageInfo.push(label + ':HTTP ' + info.status + ',' +
|
|
|
+ Math.round((info.length || 0) / 1024) + ' KB,读到 ' + courses.length + ' 门');
|
|
|
+ return courses;
|
|
|
+ } catch (error) {
|
|
|
+ stats.failedPages.push(label);
|
|
|
+ pageInfo.push(label + ':读取失败(' + (error && error.message ? error.message : error) + ')');
|
|
|
+ console.warn('[四川托普] ' + label + '读取失败:', error);
|
|
|
+ return [];
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ var personal = await loadOne('student', '学生个人课表');
|
|
|
+ var classCourses = await loadOne('class', '班级课表');
|
|
|
+
|
|
|
+ // 课程性质(选修/必修):课表页没有,单独去「学生选课情况查询」页取
|
|
|
+ try {
|
|
|
+ stats.typeMap = await loadCourseTypes(studentId, liveTerm);
|
|
|
+ stats.typeLoaded = Object.keys(stats.typeMap).length > 0;
|
|
|
+ pageInfo.push('课程性质:读到 ' + Object.keys(stats.typeMap).length + ' 门');
|
|
|
+ } catch (error) {
|
|
|
+ stats.typeMap = {};
|
|
|
+ console.warn('[四川托普] 课程性质读取失败(不影响导入,只影响选修课隔离):', error);
|
|
|
+ pageInfo.push('课程性质:读取失败');
|
|
|
+ }
|
|
|
+
|
|
|
+ var courses = applyCustomTimes(mergeCourses(personal, classCourses, stats, stats.typeMap));
|
|
|
+
|
|
|
+ if (!courses.length) {
|
|
|
+ window.shiguangBridge.showToast('两个课表页都还没有排课。');
|
|
|
+ var emptyLines = ['学生个人课表和班级课表都没有读到课程。', ''];
|
|
|
+ pageInfo.forEach(function (line) { emptyLines.push(line); });
|
|
|
+ emptyLines.push('');
|
|
|
+ emptyLines.push('请确认教务系统里选的是本学期的学年和学期;开学初期课表可能还没排出来,过几天再试。');
|
|
|
+ await window.shiguangBridgePromise.showAlert('没有读到课程', emptyLines.join('\n'), '确定');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 先写作息与课表设置,最后写课程:前面失败时不会白覆盖已有课表
|
|
|
+ var timeSlotsSaved = true;
|
|
|
+ try {
|
|
|
+ timeSlotsSaved = (await window.shiguangBridgePromise.savePresetTimeSlots(
|
|
|
+ JSON.stringify(TIME_SLOTS_DEFAULT)
|
|
|
+ )) === true;
|
|
|
+ } catch (error) {
|
|
|
+ timeSlotsSaved = false;
|
|
|
+ console.warn('[四川托普] 作息时间没有写入成功:', error);
|
|
|
+ }
|
|
|
+
|
|
|
+ try {
|
|
|
+ if (typeof window.shiguangBridgePromise.saveCourseConfig === 'function') {
|
|
|
+ var totalWeeks = courses.reduce(function (max, course) {
|
|
|
+ return Math.max(max, course.weeks[course.weeks.length - 1] || 0);
|
|
|
+ }, 0);
|
|
|
+ await window.shiguangBridgePromise.saveCourseConfig(JSON.stringify({
|
|
|
+ semesterTotalWeeks: totalWeeks,
|
|
|
+ defaultClassDuration: 45,
|
|
|
+ firstDayOfWeek: 1
|
|
|
+ }));
|
|
|
+ }
|
|
|
+ } catch (error) {
|
|
|
+ console.warn('[四川托普] 课表设置没有写入成功:', error);
|
|
|
+ }
|
|
|
+
|
|
|
+ window.shiguangBridge.showToast('正在保存 ' + courses.length + ' 门课程...');
|
|
|
+ var saveResult = await window.shiguangBridgePromise.saveImportedCourses(
|
|
|
+ JSON.stringify(courses, null, 2)
|
|
|
+ );
|
|
|
+ if (saveResult !== true) {
|
|
|
+ window.shiguangBridge.showToast('课程没有保存成功,请重试。');
|
|
|
+ await window.shiguangBridgePromise.showAlert(
|
|
|
+ '导入未完成',
|
|
|
+ '课程没有保存成功,请重新导入一次。',
|
|
|
+ '确定'
|
|
|
+ );
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ var customCount = courses.filter(function (course) { return course.isCustomTime; }).length;
|
|
|
+ var untimedCount = countCoursesWithoutTime(document);
|
|
|
+
|
|
|
+ var message = ['已导入 ' + courses.length + ' 门课程。'];
|
|
|
+ message.push('其中必修类 ' + stats.requiredFromClass + ' 门以班级课表为准(班级课表调课会及时更新)' +
|
|
|
+ (stats.requiredFromPersonal ? ',另有 ' + stats.requiredFromPersonal + ' 门班级课表里没有、从个人课表补上' : '') +
|
|
|
+ ';选修课 ' + stats.electiveFromPersonal + ' 门以个人课表为准。');
|
|
|
+ if (!stats.electiveFromPersonal && !stats.requiredFromPersonal) {
|
|
|
+ message.push('学生个人课表这次没有课程,整份课表都来自班级课表。');
|
|
|
+ }
|
|
|
+ message.push(timeSlotsSaved ? '上课时间已按学校作息设置。' : '作息时间没有写进去,请在「课表设置」里手动填写。');
|
|
|
+ if (customCount) {
|
|
|
+ message.push('其中 ' + customCount + ' 门课的上课时间与默认作息不同,已经单独设置。');
|
|
|
+ }
|
|
|
+ if (!stats.typeLoaded) {
|
|
|
+ message.push('这次没能读到课程性质,选修课与必修课没有区分,全部按班级课表优先处理。');
|
|
|
+ }
|
|
|
+ if (untimedCount) {
|
|
|
+ message.push('另有 ' + untimedCount + ' 门课程没有固定上课时间(实践、实训类),没有导入,可以自己手动添加。');
|
|
|
+ }
|
|
|
+ if (stats.failedPages.length) {
|
|
|
+ message.push('(' + stats.failedPages.join('、') + '这次没有读到,课表可能不完整,可以重试一次。)');
|
|
|
+ }
|
|
|
+ if (stats.unrecognized.length) {
|
|
|
+ message.push('有 ' + stats.unrecognized.length + ' 处内容没能识别,课表可能不完整。');
|
|
|
+ }
|
|
|
+ if (stats.sectionOverflow) {
|
|
|
+ message.push('有 ' + stats.sectionOverflow + ' 门课的节次超出了作息表范围,时间可能不准。');
|
|
|
+ }
|
|
|
+
|
|
|
+ window.shiguangBridge.showToast('导入完成,共 ' + courses.length + ' 门课程。');
|
|
|
+ await window.shiguangBridgePromise.showAlert('导入完成', message.join('\n'), '确定');
|
|
|
+ if (window.shiguangBridge && window.shiguangBridge.notifyTaskCompletion) {
|
|
|
+ window.shiguangBridge.notifyTaskCompletion();
|
|
|
+ }
|
|
|
+
|
|
|
+ console.log('[四川托普] 取数明细:' + pageInfo.join(';'));
|
|
|
+ console.log('[四川托普] 课程性质:' + JSON.stringify(stats.typeMap) + '(班级课表里属于选修、按个人课表处理的条目 ' + stats.classElectiveSkipped + ' 条)');
|
|
|
+ console.log('[四川托普] 导入完成:' + courses.length + ' 门课程(必修来自班级课表 ' + stats.requiredFromClass +
|
|
|
+ ',选修来自个人课表 ' + stats.electiveFromPersonal + ',个人课表兜底补上 ' + stats.requiredFromPersonal +
|
|
|
+ ';班级课表里属于选修的条目被丢弃 ' + stats.classElectiveSkipped + ' 条,被班级课表覆盖的个人必修 ' + stats.requiredSkippedPersonal + ' 条)');
|
|
|
+ if (stats.unrecognized.length) console.warn('[四川托普] 没能识别的内容:', stats.unrecognized);
|
|
|
+ if (stats.dayMismatch) console.warn('[四川托普] 有 ' + stats.dayMismatch + ' 条课程的星期与所在列不一致,已按格内文字处理');
|
|
|
+ if (stats.incomplete) console.warn('[四川托普] 有 ' + stats.incomplete + ' 条课程缺少教师或地点');
|
|
|
+ }
|
|
|
+
|
|
|
+ runImportFlow().catch(function (error) {
|
|
|
+ console.error('[四川托普]', error);
|
|
|
+ window.shiguangBridge.showToast('导入出错:' + (error && error.message ? error.message : error));
|
|
|
+ if (window.shiguangBridgePromise && window.shiguangBridgePromise.showAlert) {
|
|
|
+ window.shiguangBridgePromise
|
|
|
+ .showAlert('导入出错', '导入过程中出错了:' + (error && error.message ? error.message : error) + '\n可以重试一次。', '确定')
|
|
|
+ .catch(function () {});
|
|
|
+ }
|
|
|
+ });
|
|
|
+})();
|