cdutcm_01.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388
  1. // CDUTCM_01.js
  2. // 成都中医药大学教务系统适配脚本
  3. // 系统厂商:广州乘方科技有限公司(CFKJ / entss)
  4. //
  5. // 数据源(两个接口组合):
  6. // 1. 课程任务列表:/xsgrkbcx!xsAllKbList.action?xnxqdm=YYYYNN
  7. // 字段:kcmc, kcbh, jxbmc, kcrwdm, jcdm2, zcs(数组), xq, jxcdmcs(备选), teaxms
  8. // 用途:教师(teaxms)、班分子(jxbmc 中的 [N-M])、课程编号
  9. //
  10. // 2. 排课详情:/xsgrkbcx!getSkxxDataList.action?kcrwdm=X&teadm=
  11. // 字段:kxh, zc(单值), xq, jcdm2, kcmc, jxcdmc(精确), jxbmc, sknrjj
  12. // 用途:精确教室(jxcdmc),按单次具体排课展开
  13. //
  14. // 关联键:(kcmc + xq + jcdm2) 三元组
  15. // 36 条课程任务只对应 13 个不同 kcrwdm,并发 13 次 fetch 即覆盖全部排课详情
  16. // ===== 解析周次字符串为升序 number[] =====
  17. function parseWeeks(zcs) {
  18. if (!zcs) return [];
  19. const set = new Set();
  20. String(zcs).split(',').forEach(part => {
  21. const n = parseInt(String(part).trim(), 10);
  22. if (!isNaN(n) && n > 0) set.add(n);
  23. });
  24. return Array.from(set).sort((a, b) => a - b);
  25. }
  26. // ===== 解析节次字符串为 number[](保留 0,用于识别早读)=====
  27. function parsePeriods(jcdm2) {
  28. if (!jcdm2) return [];
  29. return String(jcdm2).split(',')
  30. .map(s => parseInt(String(s).trim(), 10))
  31. .filter(n => !isNaN(n));
  32. }
  33. // ===== 拆分教学班名称中的 [N-M] 班分子 =====
  34. function splitClassInfo(jxbmc) {
  35. const text = String(jxbmc || '').trim();
  36. const m = text.match(/^(.*?)\[(\d+)-(\d+)\]\s*$/);
  37. if (m) {
  38. return { base: m[1].trim(), range: `${m[2]}-${m[3]}` };
  39. }
  40. return { base: text, range: '' };
  41. }
  42. // ===== 早读默认时段 =====
  43. const MORNING_READING_START = '08:00';
  44. const MORNING_READING_END = '08:20';
  45. // ===== 时间模板(节次时间表,CDUTCM 教务不直接暴露)=====
  46. // 推导规则:每节 40 分钟,节间 10 分钟
  47. // 00 早读:08:00-08:20
  48. // 01-05 上午:08:30 起
  49. // 06-09 下午:14:00 起
  50. // 10-12 晚上:18:30 起
  51. const TIME_SLOTS = [
  52. { number: 0, startTime: '08:00', endTime: '08:20' },
  53. { number: 1, startTime: '08:30', endTime: '09:10' },
  54. { number: 2, startTime: '09:20', endTime: '10:00' },
  55. { number: 3, startTime: '10:10', endTime: '10:50' },
  56. { number: 4, startTime: '11:00', endTime: '11:40' },
  57. { number: 5, startTime: '11:50', endTime: '12:30' },
  58. { number: 6, startTime: '14:00', endTime: '14:40' },
  59. { number: 7, startTime: '14:50', endTime: '15:30' },
  60. { number: 8, startTime: '15:40', endTime: '16:20' },
  61. { number: 9, startTime: '16:30', endTime: '17:10' },
  62. { number: 10, startTime: '18:30', endTime: '19:10' },
  63. { number: 11, startTime: '19:20', endTime: '20:00' },
  64. { number: 12, startTime: '20:10', endTime: '20:50' }
  65. ];
  66. // ===== 学期配置 =====
  67. const SEMESTER_DEFAULT_START_DATE = '2026-08-31';
  68. const SEMESTER_TOTAL_WEEKS = 22;
  69. const DEFAULT_CLASS_DURATION = 40;
  70. const DEFAULT_BREAK_DURATION = 10;
  71. const FIRST_DAY_OF_WEEK = 1;
  72. function isValidDateString(s) {
  73. if (!s) return false;
  74. const m = String(s).match(/^(\d{4})-(\d{2})-(\d{2})$/);
  75. if (!m) return false;
  76. const year = parseInt(m[1], 10);
  77. const month = parseInt(m[2], 10);
  78. const day = parseInt(m[3], 10);
  79. if (month < 1 || month > 12 || day < 1 || day > 31) return false;
  80. const date = new Date(s);
  81. if (isNaN(date.getTime())) return false;
  82. return date.getFullYear() === year
  83. && date.getMonth() + 1 === month
  84. && date.getDate() === day;
  85. }
  86. async function promptSemesterStartDate() {
  87. while (true) {
  88. const input = await window.shiguangBridgePromise.showPrompt(
  89. '学期开始日期',
  90. '请输入本学期第一周周一的日期(YYYY-MM-DD)',
  91. SEMESTER_DEFAULT_START_DATE,
  92. null
  93. );
  94. if (input === null) return null;
  95. if (isValidDateString(input)) return input;
  96. await window.shiguangBridgePromise.showAlert(
  97. '日期格式错误',
  98. `请输入 YYYY-MM-DD 格式,例如 ${SEMESTER_DEFAULT_START_DATE}`,
  99. '重试'
  100. );
  101. }
  102. }
  103. // ===== HTTP 工具 =====
  104. async function fetchWithTimeout(url, options = {}, timeoutMs = 15000) {
  105. const controller = new AbortController();
  106. const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
  107. try {
  108. return await fetch(url, Object.assign({}, options, { signal: controller.signal }));
  109. } finally {
  110. clearTimeout(timeoutId);
  111. }
  112. }
  113. const COMMON_HEADERS = {
  114. 'Referer': 'https://jwweb.cdutcm.edu.cn/xsgrkbcx!xsgrkbMain.action',
  115. 'X-Requested-With': 'XMLHttpRequest'
  116. };
  117. // ===== 抓取课程任务列表(含教师、班分子、周次数组)=====
  118. async function fetchKbTaskList(xnxqdm) {
  119. const url = `/xsgrkbcx!xsAllKbList.action?xnxqdm=${encodeURIComponent(xnxqdm)}`;
  120. const response = await fetchWithTimeout(url, {
  121. method: 'GET',
  122. credentials: 'include',
  123. headers: COMMON_HEADERS
  124. });
  125. if (!response.ok) throw new Error(`课程任务列表请求失败(HTTP ${response.status})`);
  126. const text = await response.text();
  127. return extractKbxx(text);
  128. }
  129. // ===== 抓取学期下拉框(用于让用户选学期)=====
  130. async function fetchXsgrkbListPage() {
  131. const currentYear = new Date().getFullYear();
  132. const candidateXnxqdm = [`${currentYear}01`, `${currentYear - 1}01`];
  133. let lastError = null;
  134. for (const xnxqdm of candidateXnxqdm) {
  135. try {
  136. const response = await fetchWithTimeout(
  137. `/xsgrkbcx!getXsgrbkList.action?xnxqdm=${encodeURIComponent(xnxqdm)}`,
  138. { method: 'GET', credentials: 'include', headers: COMMON_HEADERS }
  139. );
  140. if (!response.ok) { lastError = `HTTP ${response.status}`; continue; }
  141. const text = await response.text();
  142. if (extractSemesterOptions(text)) return text;
  143. } catch (e) {
  144. lastError = e.message || String(e);
  145. }
  146. }
  147. throw new Error(
  148. `无法获取学期下拉框(${lastError || '无可用学期码'})。` +
  149. `请先在浏览器里手动打开「信息查询 → 学生个人课表查询 → 我的课表」后再运行脚本。`
  150. );
  151. }
  152. // ===== 抓取单个教学任务的精确排课(并发调用)=====
  153. async function fetchSkxx(kcrwdm) {
  154. const url = `/xsgrkbcx!getSkxxDataList.action?kcrwdm=${encodeURIComponent(kcrwdm)}&teadm=`;
  155. const response = await fetchWithTimeout(url, {
  156. method: 'GET',
  157. credentials: 'include',
  158. headers: COMMON_HEADERS
  159. });
  160. if (!response.ok) throw new Error(`getSkxxDataList 失败(HTTP ${response.status})`);
  161. const json = await response.json();
  162. return json.rows || [];
  163. }
  164. // ===== 并发抓取所有 kcrwdm 的精确排课 =====
  165. async function fetchAllSkxx(kcrwdms) {
  166. const results = await Promise.allSettled(kcrwdms.map(k => fetchSkxx(k)));
  167. const allRows = [];
  168. const failed = [];
  169. results.forEach((r, i) => {
  170. if (r.status === 'fulfilled') {
  171. allRows.push(...r.value);
  172. } else {
  173. failed.push(kcrwdms[i]);
  174. }
  175. });
  176. return { rows: allRows, failed };
  177. }
  178. // ===== HTML/JSON 解析 =====
  179. function extractSemesterOptions(htmlText) {
  180. const selectMatch = htmlText.match(/<select[^>]*id=['"]xnxqdm['"][^>]*>([\s\S]*?)<\/select>/i);
  181. if (!selectMatch) return null;
  182. const options = [];
  183. const optionRe = /<option[^>]*value=['"]([^'"]+)['"]([^>]*)>([\s\S]*?)<\/option>/gi;
  184. let m;
  185. while ((m = optionRe.exec(selectMatch[1])) !== null) {
  186. const value = (m[1] || '').trim();
  187. const attrs = m[2] || '';
  188. const label = (m[3] || '').replace(/<[^>]+>/g, '').trim();
  189. if (!value || !label) continue;
  190. const selected = /selected/i.test(attrs);
  191. options.push({ value, label, selected });
  192. }
  193. if (options.length === 0) return null;
  194. return options;
  195. }
  196. function extractKbxx(htmlText) {
  197. const match = htmlText.match(/(?:var\s+)?kbxx\s*=\s*(\[[\s\S]*?\])\s*;/);
  198. if (!match || !match[1]) return null;
  199. try { return JSON.parse(match[1]); }
  200. catch (e) { console.warn('CDUTCM: kbxx JSON.parse failed', e); return null; }
  201. }
  202. // ===== 把 skxx rows + kbxx 任务合并成最终课程列表 =====
  203. // 关联键:kcmc + xq + jcdm2
  204. function mergeToCourses(skxxRows, kbxxTasks) {
  205. // 把 kbxx 按 (kcmc, xq, jcdm2) 索引,便于查找教师/班分子
  206. const kbxxIndex = new Map();
  207. kbxxTasks.forEach(t => {
  208. const key = `${t.kcmc}__${t.xq}__${t.jcdm2}`;
  209. if (!kbxxIndex.has(key)) kbxxIndex.set(key, t);
  210. });
  211. const courses = [];
  212. const seen = new Set();
  213. skxxRows.forEach(row => {
  214. const day = parseInt(String(row.xq || '').trim(), 10);
  215. if (isNaN(day) || day < 1 || day > 7) return;
  216. const name = String(row.kcmc || '').trim();
  217. if (!name) return;
  218. const periods = parsePeriods(row.jcdm2);
  219. if (periods.length === 0) return;
  220. const zc = parseInt(String(row.zc || '').trim(), 10);
  221. if (isNaN(zc) || zc < 1) return;
  222. // 关联到 kbxx 任务(拿教师、班分子)
  223. const key = `${name}__${row.xq}__${row.jcdm2}`;
  224. const task = kbxxIndex.get(key);
  225. const teacher = task ? String(task.teaxms || '').trim() : '';
  226. const classInfo = task ? splitClassInfo(task.jxbmc) : { base: '', range: '' };
  227. const noteParts = [];
  228. if (classInfo.base) noteParts.push(classInfo.base);
  229. if (classInfo.range) noteParts.push(`班分子 ${classInfo.range}`);
  230. if (row.sknrjj) noteParts.push(String(row.sknrjj).trim());
  231. const note = noteParts.join(' · ');
  232. // 精确教室(jxcdmc)作为 position
  233. // 空教室时(如某些实习课)兜底为「不用场地」
  234. const position = String(row.jxcdmc || '').trim() || '不用场地';
  235. const baseFields = {
  236. name,
  237. teacher,
  238. position,
  239. day,
  240. weeks: [zc],
  241. description: note,
  242. note,
  243. location: position,
  244. dayOfWeek: day,
  245. startWeek: zc,
  246. endWeek: zc
  247. };
  248. // 去重 key(含 jxcdmc,避免同课同节次同周不同教室被去重)
  249. const dedupKey = `${name}__${teacher}__${position}__${day}__${row.jcdm2}__${zc}`;
  250. if (seen.has(dedupKey)) return;
  251. seen.add(dedupKey);
  252. // 早读分支
  253. if (periods.length === 1 && periods[0] === 0) {
  254. courses.push(Object.assign({}, baseFields, {
  255. isCustomTime: true,
  256. customStartTime: MORNING_READING_START,
  257. customEndTime: MORNING_READING_END
  258. }));
  259. return;
  260. }
  261. // 普通节次
  262. const startSection = Math.min(...periods);
  263. const endSection = Math.max(...periods);
  264. if (startSection > endSection) return;
  265. courses.push(Object.assign({}, baseFields, {
  266. startSection,
  267. endSection,
  268. courseNature: undefined
  269. }));
  270. });
  271. return courses.sort((a, b) =>
  272. a.day - b.day ||
  273. (a.startSection || 0) - (b.startSection || 0) ||
  274. (a.endSection || 0) - (b.endSection || 0) ||
  275. a.name.localeCompare(b.name)
  276. );
  277. }
  278. // ===== 主流程 =====
  279. async function runImportFlow() {
  280. try {
  281. shiguangBridge.showToast('开始读取课表入口...');
  282. const listHtml = await fetchXsgrkbListPage();
  283. const semesters = extractSemesterOptions(listHtml);
  284. if (!semesters) throw new Error('未找到学期下拉框');
  285. const defaultIndex = Math.max(0, semesters.findIndex(s => s.selected));
  286. const labels = semesters.map(s => s.label);
  287. const selectedIndex = await window.shiguangBridgePromise.showSingleSelection(
  288. '选择学期',
  289. JSON.stringify(labels),
  290. defaultIndex
  291. );
  292. if (selectedIndex === null || selectedIndex < 0) {
  293. shiguangBridge.showToast('已取消导入');
  294. return;
  295. }
  296. const xnxqdm = semesters[selectedIndex].value;
  297. shiguangBridge.showToast(`正在获取 ${semesters[selectedIndex].label} 课表与精确教室...`);
  298. // 1. 抓取课程任务列表(含教师、班分子)
  299. const kbxxTasks = await fetchKbTaskList(xnxqdm);
  300. if (!kbxxTasks || kbxxTasks.length === 0) {
  301. throw new Error('该学期未解析到课程任务,请确认登录状态和所选学期');
  302. }
  303. // 2. 提取所有不同 kcrwdm,并发抓精确教室
  304. const kcrwdms = Array.from(new Set(kbxxTasks.map(t => String(t.kcrwdm || '').trim()).filter(Boolean)));
  305. if (kcrwdms.length === 0) {
  306. throw new Error('未找到 kcrwdm 字段');
  307. }
  308. const { rows: skxxRows, failed: failedKcrwdms } = await fetchAllSkxx(kcrwdms);
  309. if (skxxRows.length === 0) {
  310. throw new Error('未获取到精确教室数据,请确认登录状态');
  311. }
  312. // 3. 合并生成最终课程列表
  313. const courses = mergeToCourses(skxxRows, kbxxTasks);
  314. if (courses.length === 0) {
  315. throw new Error('未能转换为有效课程');
  316. }
  317. // 4. 保存
  318. await window.shiguangBridgePromise.saveImportedCourses(JSON.stringify(courses));
  319. await window.shiguangBridgePromise.savePresetTimeSlots(JSON.stringify(TIME_SLOTS));
  320. const semesterStartDate = await promptSemesterStartDate();
  321. if (!semesterStartDate) {
  322. shiguangBridge.showToast('已取消(未提供学期开始日期)');
  323. return;
  324. }
  325. await window.shiguangBridgePromise.saveCourseConfig(JSON.stringify({
  326. semesterStartDate,
  327. semesterTotalWeeks: SEMESTER_TOTAL_WEEKS,
  328. defaultClassDuration: DEFAULT_CLASS_DURATION,
  329. defaultBreakDuration: DEFAULT_BREAK_DURATION,
  330. firstDayOfWeek: FIRST_DAY_OF_WEEK
  331. }));
  332. let msg = `Skxx 共 ${skxxRows.length} 条(精确教室),生成 ${courses.length} 门课程`;
  333. if (failedKcrwdms.length > 0) {
  334. msg += `(${failedKcrwdms.length} 个教学任务未拿到精确教室:${failedKcrwdms.join(', ')})`;
  335. }
  336. shiguangBridge.showToast(msg);
  337. shiguangBridge.notifyTaskCompletion();
  338. } catch (error) {
  339. console.error('CDUTCM import failed:', error);
  340. await window.shiguangBridgePromise.showAlert(
  341. '导入失败',
  342. error && error.message ? error.message : String(error),
  343. '确定'
  344. );
  345. }
  346. }
  347. runImportFlow();