xyafu_01.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435
  1. // 文件: XYAFU_01.js
  2. // 功能:从信阳农林学院青果教务系统获取课程表,解析后导入到拾光课程表
  3. // 适配:信阳农林学院青果教务系统
  4. // 维护者:glxgo
  5. const BASE = `${window.location.origin}`;
  6. const CONTROL_PAGE = '/student/xkjg.wdkb.jsp?menucode=S20301';
  7. const TIMETABLE_PAGE = '/student/wsxk.xskcb10319.jsp?params=';
  8. const TIME_SLOTS_SPRING_SUMMER = [
  9. { number: 1, startTime: '08:00', endTime: '08:45' },
  10. { number: 2, startTime: '08:50', endTime: '09:35' },
  11. { number: 3, startTime: '10:00', endTime: '10:45' },
  12. { number: 4, startTime: '10:50', endTime: '11:35' },
  13. { number: 5, startTime: '14:30', endTime: '15:15' },
  14. { number: 6, startTime: '15:20', endTime: '16:05' },
  15. { number: 7, startTime: '16:30', endTime: '17:15' },
  16. { number: 8, startTime: '17:20', endTime: '18:05' },
  17. { number: 9, startTime: '19:00', endTime: '19:45' },
  18. { number: 10, startTime: '19:50', endTime: '20:35' }
  19. ];
  20. const TIME_SLOTS_AUTUMN_WINTER = [
  21. { number: 1, startTime: '08:00', endTime: '08:45' },
  22. { number: 2, startTime: '08:50', endTime: '09:35' },
  23. { number: 3, startTime: '10:00', endTime: '10:45' },
  24. { number: 4, startTime: '10:50', endTime: '11:35' },
  25. { number: 5, startTime: '14:00', endTime: '14:45' },
  26. { number: 6, startTime: '14:50', endTime: '15:35' },
  27. { number: 7, startTime: '16:00', endTime: '16:45' },
  28. { number: 8, startTime: '16:50', endTime: '17:35' },
  29. { number: 9, startTime: '19:00', endTime: '19:45' },
  30. { number: 10, startTime: '19:50', endTime: '20:35' }
  31. ];
  32. function cleanText(value) {
  33. return String(value || '')
  34. .replace(/[​-‍]/g, '')
  35. .replace(/ /g, ' ')
  36. .trim();
  37. }
  38. function encodeParams(xn, xq, xh) {
  39. return btoa(`xn=${xn}&xq=${xq}&xh=${xh}`);
  40. }
  41. function parseWeeks(weekStr) {
  42. const weeks = [];
  43. String(weekStr || '')
  44. .replace(/\s+/g, '')
  45. .split(/[,,]/)
  46. .forEach((part) => {
  47. if (!part) return;
  48. const isSingle = part.includes('单');
  49. const isDouble = part.includes('双');
  50. const rangeMatch = part.match(/(\d+)-(\d+)/);
  51. if (rangeMatch) {
  52. const start = parseInt(rangeMatch[1], 10);
  53. const end = parseInt(rangeMatch[2], 10);
  54. for (let i = start; i <= end; i++) {
  55. if (isSingle && i % 2 === 0) continue;
  56. if (isDouble && i % 2 !== 0) continue;
  57. weeks.push(i);
  58. }
  59. } else {
  60. const num = parseInt(part.replace(/[^\d]/g, ''), 10);
  61. if (!Number.isNaN(num)) weeks.push(num);
  62. }
  63. });
  64. return [...new Set(weeks)].sort((a, b) => a - b);
  65. }
  66. function decodeParams(encoded) {
  67. try {
  68. return atob(encoded);
  69. } catch (_) {
  70. return '';
  71. }
  72. }
  73. function parseXhFromEncodedParams(encoded) {
  74. const decoded = decodeParams(encoded);
  75. if (!decoded) return '';
  76. const search = new URLSearchParams(decoded);
  77. return String(search.get('xh') || '').trim();
  78. }
  79. function extractParamsFromHtml(html) {
  80. const match = String(html || '').match(/wsxk\.xskcb10319\.jsp\?params=([^"'&\s>]+)/);
  81. return match ? decodeURIComponent(match[1]) : '';
  82. }
  83. function findControlFrame(win) {
  84. try {
  85. if (win.document.querySelector('#xnxq')) return win;
  86. } catch (_) {}
  87. for (let i = 0; i < win.frames.length; i++) {
  88. try {
  89. const found = findControlFrame(win.frames[i]);
  90. if (found) return found;
  91. } catch (_) {}
  92. }
  93. return null;
  94. }
  95. function findTimetableFrame(win) {
  96. try {
  97. if (win.document.getElementById('mytable')) return win;
  98. } catch (_) {}
  99. for (let i = 0; i < win.frames.length; i++) {
  100. try {
  101. const found = findTimetableFrame(win.frames[i]);
  102. if (found) return found;
  103. } catch (_) {}
  104. }
  105. return null;
  106. }
  107. async function fetchControlDoc() {
  108. const res = await fetch(`${BASE}${CONTROL_PAGE}`, { credentials: 'include' });
  109. if (!res.ok) throw new Error(`课表控制页请求失败: ${res.status}`);
  110. const html = await res.text();
  111. return new DOMParser().parseFromString(html, 'text/html');
  112. }
  113. async function fetchTimetableDoc(xn, xq, xh) {
  114. const params = encodeParams(xn, xq, xh);
  115. const res = await fetch(`${BASE}${TIMETABLE_PAGE}${encodeURIComponent(params)}`, {
  116. method: 'GET',
  117. credentials: 'include'
  118. });
  119. if (!res.ok) throw new Error(`课表页面请求失败: ${res.status}`);
  120. const buffer = await res.arrayBuffer();
  121. let html = '';
  122. try {
  123. html = new TextDecoder('gbk').decode(buffer);
  124. } catch (_) {
  125. html = new TextDecoder('utf-8').decode(buffer);
  126. }
  127. return new DOMParser().parseFromString(html, 'text/html');
  128. }
  129. function parseSelectOptions(selectEl) {
  130. if (!selectEl) return { options: [], defaultIndex: 0 };
  131. const options = [];
  132. let defaultIndex = 0;
  133. Array.from(selectEl.querySelectorAll('option')).forEach((opt) => {
  134. const value = String(opt.value || '').trim();
  135. if (!value) return;
  136. const text = cleanText(opt.textContent) || value;
  137. if (opt.selected) defaultIndex = options.length;
  138. options.push({ value, text });
  139. });
  140. return { options, defaultIndex };
  141. }
  142. function isSpringSummerTerm(selectedText) {
  143. return /第二学期|春|夏/.test(selectedText || '');
  144. }
  145. async function resolveTermSelection() {
  146. let controlDoc = null;
  147. let controlFrame = findControlFrame(window);
  148. if (controlFrame) {
  149. controlDoc = controlFrame.document;
  150. } else {
  151. controlDoc = await fetchControlDoc();
  152. }
  153. const select = controlDoc.querySelector('#xnxq');
  154. if (!select) {
  155. throw new Error('未找到学期选择器,请先登录并打开“学生个人课表”页面');
  156. }
  157. const { options, defaultIndex } = parseSelectOptions(select);
  158. if (!options.length) {
  159. throw new Error('未读取到学期列表,请先进入“学生个人课表”页面');
  160. }
  161. const selectedIndex = await window.AndroidBridgePromise.showSingleSelection(
  162. '选择学期',
  163. JSON.stringify(options.map(item => item.text)),
  164. defaultIndex
  165. );
  166. if (selectedIndex === null || selectedIndex === -1) {
  167. throw new Error('已取消学期选择');
  168. }
  169. const selected = options[selectedIndex];
  170. const [xn, xq] = String(selected.value).split('-');
  171. if (!xn || typeof xq === 'undefined') {
  172. throw new Error(`学期值解析失败: ${selected.value}`);
  173. }
  174. let encodedParams = extractParamsFromHtml(controlDoc.documentElement.outerHTML);
  175. if (!encodedParams) {
  176. const timetableFrame = findTimetableFrame(window);
  177. if (timetableFrame) {
  178. const url = new URL(timetableFrame.location.href);
  179. encodedParams = url.searchParams.get('params') || '';
  180. }
  181. }
  182. const xh = parseXhFromEncodedParams(encodedParams);
  183. if (!xh) {
  184. throw new Error('未读取到学号参数,请先点击进入“学生个人课表”后再导入');
  185. }
  186. return {
  187. xn,
  188. xq,
  189. xh,
  190. selectedValue: selected.value,
  191. selectedText: selected.text,
  192. isSpringSummer: isSpringSummerTerm(selected.text)
  193. };
  194. }
  195. function findTable(doc) {
  196. return doc.getElementById('mytable')
  197. || Array.from(doc.querySelectorAll('table')).find(table => {
  198. const text = cleanText(table.innerText);
  199. return text.includes('星期一') && text.includes('[');
  200. })
  201. || null;
  202. }
  203. function parseCellByDivBlocks(cell, day) {
  204. const blocks = Array.from(cell.querySelectorAll('div[style*="padding-bottom:5px"], div[style*="padding-bottom: 5px"]'));
  205. if (!blocks.length) return [];
  206. const items = [];
  207. blocks.forEach((block) => {
  208. const lines = block.innerText
  209. .split(/\n+/)
  210. .map(cleanText)
  211. .filter(Boolean);
  212. const joined = lines.join('\n');
  213. const match = joined.match(/([\d,-单双]+)\[(\d+)-(\d+)\]/);
  214. if (!match) return;
  215. const before = joined.slice(0, match.index).split(/\n+/).map(cleanText).filter(Boolean);
  216. const after = joined.slice(match.index + match[0].length).split(/\n+/).map(cleanText).filter(Boolean);
  217. const name = before[0] || '';
  218. const teacher = before[1] || '';
  219. if (!name) return;
  220. const weeks = parseWeeks(match[1]);
  221. const startSection = parseInt(match[2], 10);
  222. const endSection = parseInt(match[3], 10);
  223. if (!weeks.length) return;
  224. items.push({
  225. name,
  226. teacher,
  227. position: after.join(' '),
  228. day,
  229. startSection,
  230. endSection,
  231. weeks
  232. });
  233. });
  234. return items;
  235. }
  236. function parseCellByTextFallback(cell, day) {
  237. const lines = cell.innerText
  238. .split(/\n+/)
  239. .map(cleanText)
  240. .filter(Boolean);
  241. if (!lines.length) return [];
  242. const items = [];
  243. const textLines = lines.map(line => {
  244. const m = line.match(/^(.+?)\s+([\d,\-单双]+)\[(\d+)-(\d+)\]\s+(.+)$/);
  245. if (m) {
  246. return { raw: line, match: { nameTeacher: m[1], weeks: parseWeeks(m[2]), startSection: parseInt(m[3], 10), endSection: parseInt(m[4], 10), position: m[5] } };
  247. }
  248. return { raw: line, match: null };
  249. });
  250. const timeBlocks = textLines.filter(tl => tl.match);
  251. timeBlocks.forEach((tl) => {
  252. const m = tl.match;
  253. const name = cleanText(m.nameTeacher).replace(/\s+/g, '');
  254. items.push({
  255. name,
  256. teacher: '',
  257. position: m.position,
  258. day,
  259. startSection: m.startSection,
  260. endSection: m.endSection,
  261. weeks: m.weeks
  262. });
  263. });
  264. return items;
  265. }
  266. function parseAndMergeQingguoTable(doc) {
  267. const table = findTable(doc);
  268. if (!table) {
  269. throw new Error('未找到课表表格,请先进入“学生个人课表”页面');
  270. }
  271. const rawItems = [];
  272. Array.from(table.rows).forEach((row) => {
  273. const cells = Array.from(row.cells);
  274. if (cells.length < 7) return;
  275. cells.forEach((cell, colIndex) => {
  276. const distanceToLast = cells.length - 1 - colIndex;
  277. if (distanceToLast > 6) return;
  278. const day = 7 - distanceToLast;
  279. const rawText = cleanText(cell.innerText);
  280. if (!rawText.includes('[')) return;
  281. const divParsed = parseCellByDivBlocks(cell, day);
  282. if (divParsed.length) {
  283. rawItems.push(...divParsed);
  284. return;
  285. }
  286. const textParsed = parseCellByTextFallback(cell, day);
  287. if (textParsed.length) {
  288. rawItems.push(...textParsed);
  289. return;
  290. }
  291. });
  292. });
  293. const groupMap = new Map();
  294. rawItems.forEach((item) => {
  295. if (!item || !item.name || !item.weeks.length) return;
  296. const key = `${item.name}|${item.teacher}|${item.position}|${item.day}`;
  297. if (!groupMap.has(key)) groupMap.set(key, {});
  298. const weekMap = groupMap.get(key);
  299. item.weeks.forEach((week) => {
  300. if (!weekMap[week]) weekMap[week] = new Set();
  301. for (let section = item.startSection; section <= item.endSection; section++) {
  302. weekMap[week].add(section);
  303. }
  304. });
  305. });
  306. const finalCourses = [];
  307. groupMap.forEach((weekMap, key) => {
  308. const [name, teacher, position, day] = key.split('|');
  309. const patternMap = new Map();
  310. Object.keys(weekMap).forEach((weekStr) => {
  311. const week = parseInt(weekStr, 10);
  312. const sections = Array.from(weekMap[week]).sort((a, b) => a - b);
  313. if (!sections.length) return;
  314. let start = sections[0];
  315. for (let i = 0; i < sections.length; i++) {
  316. if (i === sections.length - 1 || sections[i + 1] !== sections[i] + 1) {
  317. const pKey = `${start}-${sections[i]}`;
  318. if (!patternMap.has(pKey)) patternMap.set(pKey, []);
  319. patternMap.get(pKey).push(week);
  320. if (i < sections.length - 1) start = sections[i + 1];
  321. }
  322. }
  323. });
  324. patternMap.forEach((weeks, patternKey) => {
  325. const [startSection, endSection] = patternKey.split('-').map(Number);
  326. finalCourses.push({
  327. name,
  328. teacher,
  329. position,
  330. day: parseInt(day, 10),
  331. startSection,
  332. endSection,
  333. weeks: weeks.sort((a, b) => a - b)
  334. });
  335. });
  336. });
  337. return finalCourses;
  338. }
  339. async function loadTimetableDoc(term) {
  340. const currentTableFrame = findTimetableFrame(window);
  341. if (currentTableFrame && currentTableFrame.document.getElementById('mytable')) {
  342. const currentUrl = new URL(currentTableFrame.location.href);
  343. const currentParams = currentUrl.searchParams.get('params') || '';
  344. const decoded = decodeParams(currentParams);
  345. if (decoded.includes(`xn=${term.xn}`) && decoded.includes(`xq=${term.xq}`)) {
  346. return currentTableFrame.document;
  347. }
  348. }
  349. return await fetchTimetableDoc(term.xn, term.xq, term.xh);
  350. }
  351. async function runImportFlow() {
  352. try {
  353. const confirmed = await window.AndroidBridgePromise.showAlert(
  354. '信阳农林学院教务导入',
  355. '请确认你已登录教务系统,并且最好已经打开“学生个人课表”页面。',
  356. '确定,开始导入'
  357. );
  358. if (!confirmed) return;
  359. const term = await resolveTermSelection();
  360. AndroidBridge.showToast('正在提取青果课表数据...');
  361. const timeSlots = term.isSpringSummer ? TIME_SLOTS_SPRING_SUMMER : TIME_SLOTS_AUTUMN_WINTER;
  362. const doc = await loadTimetableDoc(term);
  363. const courses = parseAndMergeQingguoTable(doc);
  364. if (!courses.length) {
  365. throw new Error('未找到有效课程,请确认当前学期课表已正常显示');
  366. }
  367. const allWeeks = courses.flatMap(course => course.weeks);
  368. const semesterTotalWeeks = allWeeks.length ? Math.max(...allWeeks) : 20;
  369. await window.AndroidBridgePromise.saveCourseConfig(JSON.stringify({
  370. semesterTotalWeeks,
  371. semesterStartDate: null,
  372. firstDayOfWeek: 1
  373. }));
  374. await window.AndroidBridgePromise.savePresetTimeSlots(JSON.stringify(timeSlots));
  375. await window.AndroidBridgePromise.saveImportedCourses(JSON.stringify(courses));
  376. AndroidBridge.showToast(`导入成功:共 ${courses.length} 门课程`);
  377. AndroidBridge.notifyTaskCompletion();
  378. } catch (error) {
  379. console.error(error);
  380. AndroidBridge.showToast(`导入失败: ${error.message}`);
  381. }
  382. }
  383. runImportFlow();