sdwcvc_01.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340
  1. // 山东水利职业学院树维 EAMS 拾光适配脚本
  2. // 适配作者:小漫君
  3. // 参数拆分、周次位图、逐周合并与日期校验复用拾光 ZUA/zua.js(沿用 HFNU/hfnu.js 的 EAMS 实现)。
  4. // TaskActivity 解析参考以上实现;教学周历英文日期格式参考 HIIT/hiit_01.js。
  5. // 上游:https://github.com/ShiGuangSchedule/shiguang_warehouse/tree/pending/resources
  6. // 按上游 MIT 许可保留来源;账号、学生参数、学期和开学日期均不写死。
  7. const BASE_URL = 'https://jw.sdwcvc.edu.cn';
  8. const MAX_SUPPORTED_WEEK = 60;
  9. // 来源:学校官网“作息时间”公布的统一作息(第 1~10 节)。
  10. // https://www.sdwcvc.edu.cn/ggfw/zxsj.htm
  11. const SDWCVC_TIME_SLOTS = [
  12. { number: 1, startTime: '08:15', endTime: '09:00' },
  13. { number: 2, startTime: '09:10', endTime: '09:55' },
  14. { number: 3, startTime: '10:10', endTime: '10:55' },
  15. { number: 4, startTime: '11:05', endTime: '11:50' },
  16. { number: 5, startTime: '14:30', endTime: '15:15' },
  17. { number: 6, startTime: '15:25', endTime: '16:10' },
  18. { number: 7, startTime: '16:20', endTime: '17:05' },
  19. { number: 8, startTime: '17:15', endTime: '18:00' },
  20. { number: 9, startTime: '19:30', endTime: '20:15' },
  21. { number: 10, startTime: '20:25', endTime: '21:10' }
  22. ];
  23. function powerSplit(paramsRaw) {
  24. const args = [];
  25. let current = "";
  26. let depth = 0;
  27. let inQuote = false;
  28. let quoteChar = "";
  29. for (let i = 0; i < paramsRaw.length; i++) {
  30. const char = paramsRaw[i];
  31. if ((char === '"' || char === "'") && (i === 0 || paramsRaw[i - 1] !== "\\")) {
  32. if (!inQuote) {
  33. inQuote = true;
  34. quoteChar = char;
  35. } else if (char === quoteChar) {
  36. inQuote = false;
  37. }
  38. }
  39. if (!inQuote) {
  40. if (char === "(" || char === "[" || char === "{") depth++;
  41. if (char === ")" || char === "]" || char === "}") depth--;
  42. }
  43. if (char === "," && depth === 0 && !inQuote) {
  44. args.push(cleanArg(current));
  45. current = "";
  46. } else {
  47. current += char;
  48. }
  49. }
  50. args.push(cleanArg(current));
  51. return args;
  52. }
  53. function cleanArg(value) {
  54. const trimmed = value.trim();
  55. if (trimmed === "null") return null;
  56. return trimmed.replace(/^["']|["']$/g, "");
  57. }
  58. function parseWeeksBitmap(bitmap) {
  59. const weeks = [];
  60. const value = String(bitmap || "");
  61. // 树维 EAMS 位图的下标就是周次,下标 0 是占位符;拾光使用 1 基周次。
  62. for (let week = 1; week < value.length && week <= MAX_SUPPORTED_WEEK; week++) {
  63. if (value[week] === "1") weeks.push(week);
  64. }
  65. return weeks;
  66. }
  67. /**
  68. * 沿用 HFNU 的按周次矩阵合并算法:只有课程名、教师、地点和星期均相同
  69. * 且在同一周内节次连续时才合并。remark 不参与分组,重复实验项会由 Set 去重。
  70. */
  71. function mergeContinuousLessons(lessons) {
  72. if (!lessons || lessons.length === 0) return [];
  73. const groups = {};
  74. lessons.forEach(lesson => {
  75. const key = `${lesson.name}|${lesson.teacher}|${lesson.position}|${lesson.day}`;
  76. if (!groups[key]) {
  77. groups[key] = {
  78. name: lesson.name,
  79. teacher: lesson.teacher,
  80. position: lesson.position,
  81. day: lesson.day,
  82. weeksMatrix: Array.from({ length: MAX_SUPPORTED_WEEK + 1 }, () => new Set())
  83. };
  84. }
  85. if (Array.isArray(lesson.weeks)) {
  86. lesson.weeks.forEach(week => {
  87. if (Number.isInteger(week) && week > 0 && week <= MAX_SUPPORTED_WEEK) {
  88. for (let section = lesson.startSection; section <= lesson.endSection; section++) {
  89. groups[key].weeksMatrix[week].add(section);
  90. }
  91. }
  92. });
  93. }
  94. });
  95. const merged = [];
  96. for (const key in groups) {
  97. const group = groups[key];
  98. const blockMap = {};
  99. for (let week = 1; week < group.weeksMatrix.length; week++) {
  100. const sections = Array.from(group.weeksMatrix[week]).sort((a, b) => a - b);
  101. if (sections.length === 0) continue;
  102. let start = sections[0];
  103. let previous = sections[0];
  104. for (let i = 1; i < sections.length; i++) {
  105. const current = sections[i];
  106. if (current === previous + 1) {
  107. previous = current;
  108. } else {
  109. const blockKey = `${start}-${previous}`;
  110. if (!blockMap[blockKey]) blockMap[blockKey] = [];
  111. blockMap[blockKey].push(week);
  112. start = current;
  113. previous = current;
  114. }
  115. }
  116. const blockKey = `${start}-${previous}`;
  117. if (!blockMap[blockKey]) blockMap[blockKey] = [];
  118. blockMap[blockKey].push(week);
  119. }
  120. for (const blockKey in blockMap) {
  121. const [startSection, endSection] = blockKey.split("-").map(Number);
  122. merged.push({
  123. name: group.name,
  124. teacher: group.teacher,
  125. position: group.position,
  126. day: group.day,
  127. startSection,
  128. endSection,
  129. weeks: blockMap[blockKey]
  130. });
  131. }
  132. }
  133. merged.sort((a, b) => {
  134. if (a.day !== b.day) return a.day - b.day;
  135. if (a.startSection !== b.startSection) return a.startSection - b.startSection;
  136. if (a.name !== b.name) return a.name.localeCompare(b.name);
  137. return a.position.localeCompare(b.position);
  138. });
  139. return merged;
  140. }
  141. function normalizeNumericDate(year, month, day) {
  142. const yearNumber = Number(year);
  143. const monthNumber = Number(month);
  144. const dayNumber = Number(day);
  145. const date = new Date(Date.UTC(yearNumber, monthNumber - 1, dayNumber));
  146. if (
  147. date.getUTCFullYear() !== yearNumber ||
  148. date.getUTCMonth() + 1 !== monthNumber ||
  149. date.getUTCDate() !== dayNumber
  150. ) {
  151. return null;
  152. }
  153. return [
  154. String(yearNumber).padStart(4, "0"),
  155. String(monthNumber).padStart(2, "0"),
  156. String(dayNumber).padStart(2, "0")
  157. ].join("-");
  158. }
  159. function parseParameters(html) {
  160. const student = html.match(/bg\.form\.addInput\(\s*form\s*,\s*["']ids["']\s*,\s*["'](\d+)["']\s*\)/);
  161. const semester = html.match(/\bentityId\s*:\s*["'](\d+)["']/);
  162. if (!student || !semester) throw new Error('未识别到教务课表参数,请先登录并进入“教务系统”。');
  163. return { ids: student[1], semesterId: semester[1] };
  164. }
  165. function parseSemesters(html) {
  166. const doc = new DOMParser().parseFromString(html, 'text/html');
  167. const semesters = Array.from(doc.querySelectorAll('option')).map(function (option) {
  168. const id = option.value.trim();
  169. const name = option.textContent.trim();
  170. if (!/^\d+$/.test(id) || !/^\d{4}-\d{4}-\d+$/.test(name)) throw new Error('学期列表格式不匹配。');
  171. return { id, name };
  172. });
  173. if (semesters.length === 0) throw new Error('未获取到可选学期。');
  174. return semesters;
  175. }
  176. function parseTeacherName(block) {
  177. const teachers = block.match(/actTeachers\s*=\s*\[([\s\S]*?)\]\s*;/);
  178. if (!teachers) throw new Error('课程教师字段结构不匹配。');
  179. const names = Array.from(teachers[1].matchAll(/\bname\s*:\s*"([^"]*)"/g), function (match) { return match[1]; });
  180. if (teachers[1].trim() && names.length === 0) throw new Error('课程教师字段结构不匹配。');
  181. return Array.from(new Set(names)).join(',');
  182. }
  183. function parseTaskActivities(html) {
  184. const unitCountMatch = html.match(/\bunitCount\s*=\s*(\d+)\s*;/);
  185. if (!unitCountMatch || Number(unitCountMatch[1]) < 1) throw new Error('未获取到有效节次定义。');
  186. const unitCount = Number(unitCountMatch[1]);
  187. const indexRegex = new RegExp('index\\s*=\\s*(\\d+)\\s*\\*\\s*unitCount\\s*\\+\\s*(\\d+)\\s*;', 'g');
  188. const rawResults = [];
  189. const blocks = html.split(/var\s+teachers\s*=/);
  190. const expectedCount = (html.match(/new\s+TaskActivity\(/g) || []).length;
  191. let parsedCount = 0;
  192. for (let i = 1; i < blocks.length; i++) {
  193. const block = blocks[i];
  194. const teacher = parseTeacherName(block);
  195. const activities = Array.from(block.matchAll(/new\s+TaskActivity\(([\s\S]*?)\)\s*;/g));
  196. for (let j = 0; j < activities.length; j++) {
  197. parsedCount++;
  198. const activity = activities[j];
  199. const args = powerSplit(activity[1]);
  200. if (args.length < 7 || typeof args[3] !== 'string' || typeof args[5] !== 'string' || !/^[01]+$/.test(args[6])) {
  201. throw new Error('课程活动字段结构不匹配。');
  202. }
  203. // EAMS 的课程显示名在名称后附加 <sup ...>...</sup>,保留名称中的罗马数字等正文。
  204. const name = args[3].replace(/\([^()]*\)\s*$/, '')
  205. .replace(/<sup\b[^>]*>[\s\S]*?<\/sup>\s*$/i, '').trim();
  206. const position = args[5].trim();
  207. if (!name || args[6].length > MAX_SUPPORTED_WEEK + 1) throw new Error('课程名称或周次位图无效。');
  208. const weeks = parseWeeksBitmap(args[6]);
  209. // 全零位图没有实际教学周,不生成课程。
  210. if (weeks.length === 0) continue;
  211. const end = j + 1 < activities.length ? activities[j + 1].index : block.length;
  212. const scope = block.slice(activity.index + activity[0].length, end);
  213. indexRegex.lastIndex = 0;
  214. let match;
  215. let sectionCount = 0;
  216. while ((match = indexRegex.exec(scope)) !== null) {
  217. const day = Number(match[1]) + 1;
  218. const section = Number(match[2]) + 1;
  219. if (day < 1 || day > 7 || section < 1 || section > unitCount) throw new Error('课程星期或节次超出教务定义。');
  220. rawResults.push({ name, teacher, position, day, startSection: section, endSection: section, weeks: [...weeks] });
  221. sectionCount++;
  222. }
  223. if (sectionCount === 0) throw new Error('课程活动缺少实际上课节次。');
  224. }
  225. }
  226. if (parsedCount !== expectedCount) throw new Error('课程活动结构不匹配。');
  227. return mergeContinuousLessons(rawResults);
  228. }
  229. function parseCalendarInfo(html, semesterName) {
  230. const doc = new DOMParser().parseFromString(html, 'text/html');
  231. const termCell = Array.from(doc.querySelectorAll('td')).find(function (cell) {
  232. return /学年度\s*\/\s*学期/.test(cell.textContent);
  233. });
  234. if (termCell?.nextElementSibling?.textContent.trim().replace(/\s+/g, '-') !== semesterName) {
  235. throw new Error('返回校历与所选学期不一致。');
  236. }
  237. const text = doc.body.textContent;
  238. const match = text.match(/([A-Za-z]{3})\s+(\d{1,2}),\s+(\d{4})\s*~\s*([A-Za-z]{3})\s+(\d{1,2}),\s+(\d{4})\s*\((\d+)\)/);
  239. if (!match) throw new Error('未识别到教学周历起止日期。');
  240. const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
  241. const startDate = normalizeNumericDate(match[3], months.indexOf(match[1]) + 1, match[2]);
  242. const endDate = normalizeNumericDate(match[6], months.indexOf(match[4]) + 1, match[5]);
  243. const totalWeeks = Number(match[7]);
  244. if (!startDate || !endDate || endDate < startDate || totalWeeks < 1 || totalWeeks > MAX_SUPPORTED_WEEK ||
  245. new Date(startDate + 'T00:00:00Z').getUTCDay() !== 1) {
  246. throw new Error('教学周历日期或总周数无效。');
  247. }
  248. return { semesterStartDate: startDate, semesterTotalWeeks: totalWeeks, firstDayOfWeek: 1, defaultClassDuration: 45, defaultBreakDuration: 10 };
  249. }
  250. async function requestText(path, parameters) {
  251. const options = { credentials: 'include' };
  252. if (parameters !== undefined) {
  253. options.method = 'POST';
  254. options.headers = { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' };
  255. options.body = new URLSearchParams(parameters).toString();
  256. }
  257. const response = await fetch(BASE_URL + path, options);
  258. if (!response.ok) throw new Error('教务请求失败(HTTP ' + response.status + ')。');
  259. const responseUrl = new URL(response.url);
  260. if (responseUrl.origin === BASE_URL && responseUrl.pathname === '/eams/loginExt.action') {
  261. throw new Error('教务接口返回了登录页,请从统一认证门户重新打开“教务系统”后导入。');
  262. }
  263. return response.text();
  264. }
  265. async function runImportFlow() {
  266. try {
  267. if (window.location.origin !== BASE_URL) {
  268. await window.shiguangBridgePromise.showAlert('请进入教务系统', '请先登录统一认证,并在门户中打开“教务系统”后导入。', '知道了');
  269. return;
  270. }
  271. window.shiguangBridge.showToast('正在读取学期信息…');
  272. const parameters = parseParameters(await requestText('/eams/courseTableForStd.action'));
  273. const semesters = parseSemesters(await requestText('/eams/dataQuery.action', {
  274. dataType: 'semester', entityId: parameters.semesterId
  275. }));
  276. const currentSemesterIndex = semesters.findIndex(function (semester) { return semester.id === parameters.semesterId; });
  277. if (currentSemesterIndex < 0) throw new Error('学期列表缺少教务当前学期。');
  278. const picked = await window.shiguangBridgePromise.showSingleSelection(
  279. '选择学期', JSON.stringify(semesters.map(function (semester) { return semester.name; })),
  280. currentSemesterIndex
  281. );
  282. if (picked === null) return;
  283. if (!Number.isInteger(picked) || picked < 0 || picked >= semesters.length) throw new Error('学期选择无效。');
  284. const semester = semesters[picked];
  285. window.shiguangBridge.showToast('正在获取整学期课表…');
  286. const courseHtml = await requestText('/eams/courseTableForStd!courseTable.action', {
  287. ignoreHead: '1', 'setting.kind': 'std', startWeek: '', 'semester.id': semester.id, ids: parameters.ids
  288. });
  289. const courses = parseTaskActivities(courseHtml);
  290. if (courses.length === 0) {
  291. await window.shiguangBridgePromise.showAlert('该学期暂无课程', '所选学期没有已安排上课周次和节次的课程。', '知道了');
  292. return;
  293. }
  294. const config = parseCalendarInfo(await requestText('/eams/base/calendar-info.action', {
  295. version: '1', semesterId: semester.id
  296. }), semester.name);
  297. if (courses.some(function (course) { return course.weeks.some(function (week) { return week > config.semesterTotalWeeks; }); })) {
  298. throw new Error('课程周次超出所选学期校历。');
  299. }
  300. if (courses.some(function (course) { return course.endSection > SDWCVC_TIME_SLOTS.length; })) {
  301. throw new Error('课程节次超出学校统一作息表的第 1~10 节。');
  302. }
  303. if (!await window.shiguangBridgePromise.saveCourseConfig(JSON.stringify(config))) throw new Error('课表配置保存失败。');
  304. if (!await window.shiguangBridgePromise.savePresetTimeSlots(JSON.stringify(SDWCVC_TIME_SLOTS))) throw new Error('作息时间保存失败。');
  305. if (!await window.shiguangBridgePromise.saveImportedCourses(JSON.stringify(courses))) throw new Error('课程保存失败。');
  306. window.shiguangBridge.showToast('已获取 ' + courses.length + ' 条课程时段,并应用学校统一作息。');
  307. window.shiguangBridge.notifyTaskCompletion();
  308. } catch (error) {
  309. await window.shiguangBridgePromise.showAlert('导入失败', error.message, '知道了');
  310. }
  311. }
  312. runImportFlow();