fjpit_01.js 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442
  1. // 福建信息职业技术学院(福信智慧教务)课表导入适配脚本
  2. // 前端 jw.fjpit.com · 接口 jw-api.fjpit.com/api · 鉴权头 ba-token + server: 1
  3. // 取数直接请求接口,不解析页面 HTML;教师/教室按原文原样保留(空即空)
  4. const FJPIT_API = 'https://jw-api.fjpit.com/api';
  5. const FJPIT_HOST_KEY = 'fjpit.com';
  6. const FJPIT_LOGIN_WAIT_MS = 5 * 60 * 1000;
  7. const FJPIT_BREAK_RE = /中午|午休|午间|晚休|休息/; // 作息表里的非上课行,需剔除
  8. // ---------- 通用工具 ----------
  9. function fjpitDelay(ms) {
  10. return new Promise(function (r) { setTimeout(r, ms); });
  11. }
  12. function fjpitUuid() {
  13. try { return crypto.randomUUID(); } catch (e) { return 'p-' + Date.now() + '-' + Math.random().toString(36).slice(2); }
  14. }
  15. /** 把日期对齐到所在周的周一(教务给的 startDate 不保证是周一);用 UTC 运算避免时区挪日 */
  16. function fjpitAlignToMonday(dateStr) {
  17. const m = /^(\d{4})-(\d{1,2})-(\d{1,2})/.exec(String(dateStr == null ? '' : dateStr));
  18. if (!m) return '';
  19. const d = new Date(Date.UTC(+m[1], +m[2] - 1, +m[3]));
  20. if (isNaN(d.getTime())) return '';
  21. const dow = d.getUTCDay(); // 0 = 周日
  22. d.setUTCDate(d.getUTCDate() + (dow === 0 ? -6 : 1 - dow));
  23. const pad = function (n) { return n < 10 ? '0' + n : '' + n; };
  24. return d.getUTCFullYear() + '-' + pad(d.getUTCMonth() + 1) + '-' + pad(d.getUTCDate());
  25. }
  26. function fjpitSafeToast(msg) {
  27. try { window.shiguangBridge.showToast(msg); } catch (e) { console.log('JS[toast]: ' + msg); }
  28. }
  29. // ---------- 一、鉴权 ----------
  30. /** 从 Pinia store 取 accessToken;取不到返回 null,由调用方走「请登录」引导 */
  31. function fjpitGetAccessToken() {
  32. const root = document.querySelector('#app') || document.body.firstElementChild;
  33. const app = root && root.__vue_app__;
  34. const gp = app && app.config && app.config.globalProperties;
  35. const pinia = gp && gp.$pinia;
  36. if (!pinia || !(pinia._s instanceof Map)) return null;
  37. let fallback = null;
  38. for (const entry of pinia._s) {
  39. const store = entry[1];
  40. if (store && typeof store.accessToken === 'string' && store.accessToken) {
  41. if (String(entry[0]).indexOf('access') >= 0) return store.accessToken;
  42. if (!fallback) fallback = store.accessToken;
  43. }
  44. }
  45. return fallback;
  46. }
  47. /** 结构化接口用的头 —— 与移动端 m.fjpit.com 一致 */
  48. function fjpitApiHeaders(token) {
  49. const h = {
  50. 'Accept': 'application/json, text/plain, */*',
  51. 'Content-Type': 'application/json;charset=UTF-8',
  52. 'server': '1',
  53. 'unique-request-id': fjpitUuid(),
  54. 'Accept-Language': 'zh-CN,zh;q=0.9'
  55. };
  56. if (token) h['ba-token'] = token;
  57. return h;
  58. }
  59. // ---------- 二、取数(结构化 API) ----------
  60. // 教务 WAF 有短时速率限制(连续快速请求会被成片拒绝),故加请求间隔 + 失败退避重试
  61. // 一旦出现失败就把全局间隔翻倍(上限 800ms)自适应降速,宁可慢也要把数据取全
  62. const FJPIT_REQ_GAP_MS = 90; // 相邻请求最小间隔(起始值)
  63. const FJPIT_GAP_MAX_MS = 800; // 自适应间隔上限
  64. const FJPIT_MAX_ATTEMPT = 3; // 单个请求最多尝试次数
  65. const FJPIT_RETRY_BASE_MS = 400; // 退避基数(第 n 次重试等 n × 此值)
  66. const FJPIT_WEEK_CONCURRENCY = 5; // 逐周抓取的并发度
  67. let FJPIT_CUR_GAP_MS = FJPIT_REQ_GAP_MS;
  68. let FJPIT_LAST_REQ_AT = 0;
  69. /** 请求节流:保证相邻请求之间有最小间隔(间隔随失败自适应放宽) */
  70. async function fjpitPace() {
  71. const wait = FJPIT_CUR_GAP_MS - (Date.now() - FJPIT_LAST_REQ_AT);
  72. if (wait > 0) await fjpitDelay(wait);
  73. FJPIT_LAST_REQ_AT = Date.now();
  74. }
  75. /** 统一请求入口:节流 + 失败退避 */
  76. async function fjpitApiRequest(method, path, body, token) {
  77. let lastErr = null;
  78. for (let attempt = 1; attempt <= FJPIT_MAX_ATTEMPT; attempt++) {
  79. await fjpitPace();
  80. try {
  81. const init = {
  82. method: method,
  83. headers: fjpitApiHeaders(token),
  84. credentials: 'omit',
  85. mode: 'cors'
  86. };
  87. if (body !== undefined) init.body = JSON.stringify(body);
  88. const resp = await fetch(FJPIT_API + path, init);
  89. if (!resp.ok) throw new Error(path + ' HTTP ' + resp.status);
  90. const json = await resp.json();
  91. if (json.code !== 1) throw new Error(path + ' code=' + json.code + ' ' + (json.msg || ''));
  92. return json.data;
  93. } catch (e) {
  94. lastErr = e;
  95. // 出现失败说明很可能正在撞限流 → 全局间隔翻倍
  96. FJPIT_CUR_GAP_MS = Math.min(FJPIT_GAP_MAX_MS, FJPIT_CUR_GAP_MS * 2);
  97. if (attempt < FJPIT_MAX_ATTEMPT) {
  98. const back = FJPIT_RETRY_BASE_MS * attempt;
  99. console.warn('JS: ' + path + ' 第 ' + attempt + ' 次失败(' + e.message
  100. + '),' + back + 'ms 后重试');
  101. await fjpitDelay(back);
  102. }
  103. }
  104. }
  105. throw lastErr;
  106. }
  107. async function fjpitApiGet(path, token) {
  108. return fjpitApiRequest('GET', path, undefined, token);
  109. }
  110. async function fjpitApiPost(path, body, token) {
  111. return fjpitApiRequest('POST', path, body, token);
  112. }
  113. /** 限并发遍历:最多同时执行 limit 个 worker,结果按原顺序返回 */
  114. async function fjpitForEachLimit(items, limit, worker) {
  115. const results = new Array(items.length);
  116. const n = Math.max(1, Math.min(limit | 0, items.length));
  117. let next = 0;
  118. async function runner() {
  119. for (;;) {
  120. const i = next++;
  121. if (i >= items.length) return;
  122. results[i] = await worker(items[i], i);
  123. }
  124. }
  125. const runners = [];
  126. for (let k = 0; k < n; k++) runners.push(runner());
  127. await Promise.all(runners);
  128. return results;
  129. }
  130. /** 作息 → {slots, nodeMap}。午休/晚休不是正式节次,须剔除并把课程节次重编号 */
  131. function fjpitParseTimeSlots(raw) {
  132. const arr = Array.isArray(raw) ? raw : Object.values(raw || {});
  133. const ok = /^\d{1,2}:\d{2}$/;
  134. const all = arr.map(function (e) {
  135. return {
  136. node: Number(e && e.jcdm),
  137. name: String((e && e.jcmc) || ''),
  138. startTime: String((e && e.jcskkssj) || '').slice(0, 5),
  139. endTime: String((e && e.jcskjssj) || '').slice(0, 5)
  140. };
  141. }).filter(function (t) {
  142. return t.node >= 1 && ok.test(t.startTime) && ok.test(t.endTime);
  143. }).sort(function (a, b) { return a.node - b.node; });
  144. // 教务按「含休息行」的顺序给课程节点编号,故剔除休息行后必须同步重编号
  145. let kept = all.filter(function (t) { return !FJPIT_BREAK_RE.test(t.name); });
  146. if (!kept.length) kept = all; // 名称识别不出节次时保持原样,避免误伤
  147. const nodeMap = {};
  148. const slots = kept.map(function (t, i) {
  149. nodeMap[t.node] = i + 1;
  150. return { number: i + 1, startTime: t.startTime, endTime: t.endTime };
  151. });
  152. return { slots: slots, nodeMap: nodeMap, dropped: all.length - kept.length };
  153. }
  154. /** 节次编号折算到重排后的编号;无对应项时原样返回 */
  155. function fjpitMapNode(node, nodeMap) {
  156. const n = Number(node);
  157. return (nodeMap && nodeMap[n]) ? nodeMap[n] : n;
  158. }
  159. /** 单条课表记录 → entry;课程名/星期/节次非法的返回 null */
  160. function fjpitParseEntry(rec, week, nodeMap) {
  161. if (!rec) return null;
  162. const name = String(rec.course == null ? '' : rec.course).trim();
  163. const day = Number(rec.DayIndex);
  164. const start = fjpitMapNode(rec.startNode, nodeMap);
  165. const end = fjpitMapNode(rec.endNode, nodeMap);
  166. if (!name || !(day >= 1 && day <= 7) || !(start >= 1) || !(end >= start)) return null;
  167. return {
  168. week: week, day: day, date: '',
  169. start: start, end: end, name: name,
  170. teacher: rec.teacherName == null ? '' : String(rec.teacherName).trim(),
  171. room: rec.spaceName == null ? '' : String(rec.spaceName).trim()
  172. };
  173. }
  174. /** 走教务接口取全部数据,任一步失败直接抛错。
  175. * 并发编排:/semesters 串行 → 配置 + 作息并发 → 逐周限并发 FJPIT_WEEK_CONCURRENCY */
  176. async function fjpitCollectData(token) {
  177. const t0 = Date.now();
  178. // 1) 学期列表(必须最先,后续依赖它)
  179. const semData = await fjpitApiGet('/semesters', token);
  180. const semList = (semData && semData.semesters) || [];
  181. if (!semList.length) throw new Error('/semesters 无学期数据');
  182. const cur = semList.filter(function (s) { return s && s.isCurrent; })[0] || semList[0];
  183. const semester = cur.value;
  184. const showXxq = (cur.isCurrent && cur.isXxq) ? 1 : 0;
  185. // 2) 学期配置(关键,失败即中断)与作息(非关键)并发
  186. const slotPromise = fjpitApiPost('/scheduleTime', { dqz: 1 }, token).catch(function (e) {
  187. console.warn('JS: 作息读取失败(不影响课表): ' + e.message);
  188. return null;
  189. });
  190. const cfgData = await fjpitApiPost('/semesterConfig', { semester: semester }, token);
  191. const sc = (cfgData && cfgData.semestersConfig) || cfgData || {};
  192. const totalWeeks = Number(sc.totalWeeks) || 0;
  193. if (!(totalWeeks >= 1)) throw new Error('/semesterConfig 未返回有效 totalWeeks');
  194. const startDate = fjpitAlignToMonday(sc.startDate); // 教务给的不一定是周一
  195. const ts = fjpitParseTimeSlots(await slotPromise);
  196. console.log('JS: 作息 ' + ts.slots.length + ' 节(剔除休息行 ' + ts.dropped + ')');
  197. // 3) 逐周课表(限并发;单周失败只记账不中断)
  198. const weeks = [];
  199. for (let w = 1; w <= totalWeeks; w++) weeks.push(w);
  200. const results = await fjpitForEachLimit(weeks, FJPIT_WEEK_CONCURRENCY, async function (w) {
  201. try {
  202. const d = await fjpitApiPost('/schedule',
  203. { semester: semester, week: w, showxxq: showXxq }, token);
  204. return { week: w, list: Array.isArray(d && d.list) ? d.list : [], ok: true };
  205. } catch (e) {
  206. console.warn('JS: 第 ' + w + ' 周抓取失败: ' + e.message);
  207. return { week: w, list: [], ok: false };
  208. }
  209. });
  210. const entries = [];
  211. const failedWeeks = [];
  212. results.forEach(function (r) {
  213. if (!r.ok) { failedWeeks.push(r.week); return; }
  214. r.list.forEach(function (rec) {
  215. const e = fjpitParseEntry(rec, r.week, ts.nodeMap);
  216. if (e) entries.push(e);
  217. });
  218. });
  219. if (failedWeeks.length > Math.floor(totalWeeks / 2)) {
  220. throw new Error('过半周次抓取失败(' + failedWeeks.length + '/' + totalWeeks + '):'
  221. + failedWeeks.join(','));
  222. }
  223. const elapsedMs = Date.now() - t0;
  224. console.log('JS: 取数完成 ' + elapsedMs + 'ms;失败周次 ' + failedWeeks.length
  225. + ';请求间隔 ' + FJPIT_CUR_GAP_MS + 'ms');
  226. return {
  227. entries: entries, timeSlots: ts.slots,
  228. semesterStartDate: startDate, totalWeeks: totalWeeks,
  229. failedWeeks: failedWeeks, elapsedMs: elapsedMs
  230. };
  231. }
  232. // ---------- 三、聚合(教师/教室原样透传) ----------
  233. function fjpitAggregate(entries) {
  234. const SEP = '\u0001';
  235. const map = new Map();
  236. entries.forEach(function (e) {
  237. const key = [e.name, e.teacher, e.room, e.day, e.start, e.end].join(SEP);
  238. if (!map.has(key)) {
  239. map.set(key, {
  240. name: e.name,
  241. teacher: e.teacher, // 可能为空字符串,原样保留
  242. position: e.room, // 可能为空字符串,原样保留
  243. day: e.day,
  244. startSection: e.start,
  245. endSection: e.end,
  246. weeks: []
  247. });
  248. }
  249. const course = map.get(key);
  250. if (course.weeks.indexOf(e.week) < 0) course.weeks.push(e.week);
  251. });
  252. const list = Array.from(map.values());
  253. list.forEach(function (c) { c.weeks.sort(function (a, b) { return a - b; }); });
  254. list.sort(function (a, b) {
  255. return a.day - b.day || a.startSection - b.startSection || a.name.localeCompare(b.name);
  256. });
  257. return list;
  258. }
  259. // ---------- 四、保存 ----------
  260. async function fjpitSaveCourses(courses) {
  261. await window.shiguangBridgePromise.saveImportedCourses(JSON.stringify(courses, null, 2));
  262. }
  263. /** 作息要求编号从 1 起连续,否则不导入(返回 false) */
  264. async function fjpitSaveTimeSlots(timeSlots) {
  265. if (!timeSlots.length) return false;
  266. for (let i = 0; i < timeSlots.length; i++) {
  267. if (timeSlots[i].number !== i + 1) return false;
  268. }
  269. await window.shiguangBridgePromise.savePresetTimeSlots(JSON.stringify(timeSlots));
  270. return true;
  271. }
  272. async function fjpitSaveConfig(semesterStartDate, totalWeeks) {
  273. const config = { semesterTotalWeeks: totalWeeks };
  274. if (semesterStartDate) config.semesterStartDate = semesterStartDate;
  275. await window.shiguangBridgePromise.saveCourseConfig(JSON.stringify(config));
  276. }
  277. // ---------- 五、等待登录 ----------
  278. /** 轮询等待用户登录;页面被整页重载则提前返回 null */
  279. async function fjpitWaitForLogin(deadlineTs) {
  280. const bodyRef = document.body;
  281. let lastTip = 0;
  282. while (Date.now() < deadlineTs) {
  283. if (document.body !== bodyRef) return null;
  284. const token = fjpitGetAccessToken();
  285. if (token) return token;
  286. if (Date.now() - lastTip > 60000) { // 每分钟用原生 toast 提醒一次
  287. lastTip = Date.now();
  288. fjpitSafeToast('请在页面中登录教务账号,登录后会自动继续导入');
  289. }
  290. await fjpitDelay(700);
  291. }
  292. return null;
  293. }
  294. // ---------- 六、主流程(编排) ----------
  295. // 按官方推荐的编排模式:只做顺序编排;任一步失败立即 return;
  296. // notifyTaskCompletion() 只在完全成功后调用。
  297. /** 确保已登录;未登录则用原生 toast 提示并等待 */
  298. async function fjpitEnsureLogin() {
  299. let token = fjpitGetAccessToken();
  300. if (token) return token;
  301. fjpitSafeToast('未检测到登录状态,请在页面中登录教务账号');
  302. token = await fjpitWaitForLogin(Date.now() + FJPIT_LOGIN_WAIT_MS);
  303. if (!token) token = fjpitGetAccessToken(); // 兜底:前端可能刚把 token 写进 store
  304. return token;
  305. }
  306. /** 聚合 + 保存。课程保存失败会抛出(必须中断);作息与配置尽力而为 */
  307. async function fjpitSaveAll(data) {
  308. const courses = fjpitAggregate(data.entries);
  309. console.log('JS: 原始条目 ' + data.entries.length + ',聚合为 ' + courses.length
  310. + ' 条课程;开学日期 ' + data.semesterStartDate);
  311. await fjpitSaveCourses(courses);
  312. let timeSlotSaved = false;
  313. try {
  314. timeSlotSaved = await fjpitSaveTimeSlots(data.timeSlots || []);
  315. } catch (e) {
  316. console.warn('JS: 作息时间导入失败: ' + e.message);
  317. }
  318. try {
  319. await fjpitSaveConfig(data.semesterStartDate, data.totalWeeks);
  320. } catch (e) {
  321. console.warn('JS: 课表配置保存失败: ' + e.message);
  322. }
  323. return { courses: courses, timeSlotSaved: timeSlotSaved };
  324. }
  325. /** 汇总公告 —— 本脚本唯一的弹窗 */
  326. async function fjpitReport(data, saved) {
  327. const courses = saved.courses;
  328. const nameSet = {};
  329. courses.forEach(function (c) { nameSet[c.name] = 1; });
  330. const summary = [
  331. '导入完成',
  332. '课程行数:' + courses.length + '(' + Object.keys(nameSet).length + ' 门课)',
  333. '学期周数:' + data.totalWeeks,
  334. '开学日期:' + (data.semesterStartDate || '未取到'),
  335. '作息时间:' + (saved.timeSlotSaved ? (data.timeSlots || []).length + ' 节' : '未导入'),
  336. '取数耗时:' + (data.elapsedMs ? ((data.elapsedMs / 1000).toFixed(1) + 's') : '未知'),
  337. (data.failedWeeks && data.failedWeeks.length)
  338. ? '失败周次:' + data.failedWeeks.join(',') : '全部周次抓取成功'
  339. ];
  340. console.log('JS: ' + summary.join(' | '));
  341. await window.shiguangBridgePromise.showAlert('导入完成', summary.join('\n'), '好的');
  342. fjpitSafeToast('导入成功,共 ' + courses.length + ' 条课程');
  343. }
  344. /** 编排入口;任一步失败或用户取消即 return */
  345. async function runImportFlow() {
  346. console.log('JS: 福信智慧教务课表导入开始(v21)');
  347. // 0. 环境准备
  348. if (location.host.indexOf(FJPIT_HOST_KEY) < 0) {
  349. fjpitSafeToast('请先在福信智慧教务页面登录后再执行导入。');
  350. return;
  351. }
  352. const token = await fjpitEnsureLogin();
  353. if (!token) {
  354. fjpitSafeToast('未等到登录状态,请登录后重新点「执行导入」');
  355. return;
  356. }
  357. // 2. 取数(全部走教务接口,不解析页面 HTML)
  358. let data;
  359. try {
  360. data = await fjpitCollectData(token);
  361. } catch (e) {
  362. fjpitSafeToast('取数失败,请确认已登录教务系统后重试:' + e.message);
  363. return;
  364. }
  365. if (!data.entries.length) {
  366. fjpitSafeToast('未取到任何课程,请确认本学期是否有排课。');
  367. return;
  368. }
  369. // 3. 聚合 + 保存
  370. let saved;
  371. try {
  372. saved = await fjpitSaveAll(data);
  373. } catch (e) {
  374. fjpitSafeToast('课程保存失败:' + e.message);
  375. return;
  376. }
  377. // 4. 汇总
  378. await fjpitReport(data, saved);
  379. // 5. 完全成功,才发结束信号
  380. window.shiguangBridge.notifyTaskCompletion();
  381. }
  382. runImportFlow().catch(function (e) {
  383. console.error('JS: 导入流程异常', e);
  384. try { window.shiguangBridge.showToast('导入失败: ' + (e && e.message)); } catch (x) { }
  385. });