| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442 |
- // 福建信息职业技术学院(福信智慧教务)课表导入适配脚本
- // 前端 jw.fjpit.com · 接口 jw-api.fjpit.com/api · 鉴权头 ba-token + server: 1
- // 取数直接请求接口,不解析页面 HTML;教师/教室按原文原样保留(空即空)
- const FJPIT_API = 'https://jw-api.fjpit.com/api';
- const FJPIT_HOST_KEY = 'fjpit.com';
- const FJPIT_LOGIN_WAIT_MS = 5 * 60 * 1000;
- const FJPIT_BREAK_RE = /中午|午休|午间|晚休|休息/; // 作息表里的非上课行,需剔除
- // ---------- 通用工具 ----------
- function fjpitDelay(ms) {
- return new Promise(function (r) { setTimeout(r, ms); });
- }
- function fjpitUuid() {
- try { return crypto.randomUUID(); } catch (e) { return 'p-' + Date.now() + '-' + Math.random().toString(36).slice(2); }
- }
- /** 把日期对齐到所在周的周一(教务给的 startDate 不保证是周一);用 UTC 运算避免时区挪日 */
- function fjpitAlignToMonday(dateStr) {
- const m = /^(\d{4})-(\d{1,2})-(\d{1,2})/.exec(String(dateStr == null ? '' : dateStr));
- if (!m) return '';
- const d = new Date(Date.UTC(+m[1], +m[2] - 1, +m[3]));
- if (isNaN(d.getTime())) return '';
- const dow = d.getUTCDay(); // 0 = 周日
- d.setUTCDate(d.getUTCDate() + (dow === 0 ? -6 : 1 - dow));
- const pad = function (n) { return n < 10 ? '0' + n : '' + n; };
- return d.getUTCFullYear() + '-' + pad(d.getUTCMonth() + 1) + '-' + pad(d.getUTCDate());
- }
- function fjpitSafeToast(msg) {
- try { window.shiguangBridge.showToast(msg); } catch (e) { console.log('JS[toast]: ' + msg); }
- }
- // ---------- 一、鉴权 ----------
- /** 从 Pinia store 取 accessToken;取不到返回 null,由调用方走「请登录」引导 */
- function fjpitGetAccessToken() {
- const root = document.querySelector('#app') || document.body.firstElementChild;
- const app = root && root.__vue_app__;
- const gp = app && app.config && app.config.globalProperties;
- const pinia = gp && gp.$pinia;
- if (!pinia || !(pinia._s instanceof Map)) return null;
- let fallback = null;
- for (const entry of pinia._s) {
- const store = entry[1];
- if (store && typeof store.accessToken === 'string' && store.accessToken) {
- if (String(entry[0]).indexOf('access') >= 0) return store.accessToken;
- if (!fallback) fallback = store.accessToken;
- }
- }
- return fallback;
- }
- /** 结构化接口用的头 —— 与移动端 m.fjpit.com 一致 */
- function fjpitApiHeaders(token) {
- const h = {
- 'Accept': 'application/json, text/plain, */*',
- 'Content-Type': 'application/json;charset=UTF-8',
- 'server': '1',
- 'unique-request-id': fjpitUuid(),
- 'Accept-Language': 'zh-CN,zh;q=0.9'
- };
- if (token) h['ba-token'] = token;
- return h;
- }
- // ---------- 二、取数(结构化 API) ----------
- // 教务 WAF 有短时速率限制(连续快速请求会被成片拒绝),故加请求间隔 + 失败退避重试
- // 一旦出现失败就把全局间隔翻倍(上限 800ms)自适应降速,宁可慢也要把数据取全
- const FJPIT_REQ_GAP_MS = 90; // 相邻请求最小间隔(起始值)
- const FJPIT_GAP_MAX_MS = 800; // 自适应间隔上限
- const FJPIT_MAX_ATTEMPT = 3; // 单个请求最多尝试次数
- const FJPIT_RETRY_BASE_MS = 400; // 退避基数(第 n 次重试等 n × 此值)
- const FJPIT_WEEK_CONCURRENCY = 5; // 逐周抓取的并发度
- let FJPIT_CUR_GAP_MS = FJPIT_REQ_GAP_MS;
- let FJPIT_LAST_REQ_AT = 0;
- /** 请求节流:保证相邻请求之间有最小间隔(间隔随失败自适应放宽) */
- async function fjpitPace() {
- const wait = FJPIT_CUR_GAP_MS - (Date.now() - FJPIT_LAST_REQ_AT);
- if (wait > 0) await fjpitDelay(wait);
- FJPIT_LAST_REQ_AT = Date.now();
- }
- /** 统一请求入口:节流 + 失败退避 */
- async function fjpitApiRequest(method, path, body, token) {
- let lastErr = null;
- for (let attempt = 1; attempt <= FJPIT_MAX_ATTEMPT; attempt++) {
- await fjpitPace();
- try {
- const init = {
- method: method,
- headers: fjpitApiHeaders(token),
- credentials: 'omit',
- mode: 'cors'
- };
- if (body !== undefined) init.body = JSON.stringify(body);
- const resp = await fetch(FJPIT_API + path, init);
- if (!resp.ok) throw new Error(path + ' HTTP ' + resp.status);
- const json = await resp.json();
- if (json.code !== 1) throw new Error(path + ' code=' + json.code + ' ' + (json.msg || ''));
- return json.data;
- } catch (e) {
- lastErr = e;
- // 出现失败说明很可能正在撞限流 → 全局间隔翻倍
- FJPIT_CUR_GAP_MS = Math.min(FJPIT_GAP_MAX_MS, FJPIT_CUR_GAP_MS * 2);
- if (attempt < FJPIT_MAX_ATTEMPT) {
- const back = FJPIT_RETRY_BASE_MS * attempt;
- console.warn('JS: ' + path + ' 第 ' + attempt + ' 次失败(' + e.message
- + '),' + back + 'ms 后重试');
- await fjpitDelay(back);
- }
- }
- }
- throw lastErr;
- }
- async function fjpitApiGet(path, token) {
- return fjpitApiRequest('GET', path, undefined, token);
- }
- async function fjpitApiPost(path, body, token) {
- return fjpitApiRequest('POST', path, body, token);
- }
- /** 限并发遍历:最多同时执行 limit 个 worker,结果按原顺序返回 */
- async function fjpitForEachLimit(items, limit, worker) {
- const results = new Array(items.length);
- const n = Math.max(1, Math.min(limit | 0, items.length));
- let next = 0;
- async function runner() {
- for (;;) {
- const i = next++;
- if (i >= items.length) return;
- results[i] = await worker(items[i], i);
- }
- }
- const runners = [];
- for (let k = 0; k < n; k++) runners.push(runner());
- await Promise.all(runners);
- return results;
- }
- /** 作息 → {slots, nodeMap}。午休/晚休不是正式节次,须剔除并把课程节次重编号 */
- function fjpitParseTimeSlots(raw) {
- const arr = Array.isArray(raw) ? raw : Object.values(raw || {});
- const ok = /^\d{1,2}:\d{2}$/;
- const all = arr.map(function (e) {
- return {
- node: Number(e && e.jcdm),
- name: String((e && e.jcmc) || ''),
- startTime: String((e && e.jcskkssj) || '').slice(0, 5),
- endTime: String((e && e.jcskjssj) || '').slice(0, 5)
- };
- }).filter(function (t) {
- return t.node >= 1 && ok.test(t.startTime) && ok.test(t.endTime);
- }).sort(function (a, b) { return a.node - b.node; });
- // 教务按「含休息行」的顺序给课程节点编号,故剔除休息行后必须同步重编号
- let kept = all.filter(function (t) { return !FJPIT_BREAK_RE.test(t.name); });
- if (!kept.length) kept = all; // 名称识别不出节次时保持原样,避免误伤
- const nodeMap = {};
- const slots = kept.map(function (t, i) {
- nodeMap[t.node] = i + 1;
- return { number: i + 1, startTime: t.startTime, endTime: t.endTime };
- });
- return { slots: slots, nodeMap: nodeMap, dropped: all.length - kept.length };
- }
- /** 节次编号折算到重排后的编号;无对应项时原样返回 */
- function fjpitMapNode(node, nodeMap) {
- const n = Number(node);
- return (nodeMap && nodeMap[n]) ? nodeMap[n] : n;
- }
- /** 单条课表记录 → entry;课程名/星期/节次非法的返回 null */
- function fjpitParseEntry(rec, week, nodeMap) {
- if (!rec) return null;
- const name = String(rec.course == null ? '' : rec.course).trim();
- const day = Number(rec.DayIndex);
- const start = fjpitMapNode(rec.startNode, nodeMap);
- const end = fjpitMapNode(rec.endNode, nodeMap);
- if (!name || !(day >= 1 && day <= 7) || !(start >= 1) || !(end >= start)) return null;
- return {
- week: week, day: day, date: '',
- start: start, end: end, name: name,
- teacher: rec.teacherName == null ? '' : String(rec.teacherName).trim(),
- room: rec.spaceName == null ? '' : String(rec.spaceName).trim()
- };
- }
- /** 走教务接口取全部数据,任一步失败直接抛错。
- * 并发编排:/semesters 串行 → 配置 + 作息并发 → 逐周限并发 FJPIT_WEEK_CONCURRENCY */
- async function fjpitCollectData(token) {
- const t0 = Date.now();
- // 1) 学期列表(必须最先,后续依赖它)
- const semData = await fjpitApiGet('/semesters', token);
- const semList = (semData && semData.semesters) || [];
- if (!semList.length) throw new Error('/semesters 无学期数据');
- const cur = semList.filter(function (s) { return s && s.isCurrent; })[0] || semList[0];
- const semester = cur.value;
- const showXxq = (cur.isCurrent && cur.isXxq) ? 1 : 0;
- // 2) 学期配置(关键,失败即中断)与作息(非关键)并发
- const slotPromise = fjpitApiPost('/scheduleTime', { dqz: 1 }, token).catch(function (e) {
- console.warn('JS: 作息读取失败(不影响课表): ' + e.message);
- return null;
- });
- const cfgData = await fjpitApiPost('/semesterConfig', { semester: semester }, token);
- const sc = (cfgData && cfgData.semestersConfig) || cfgData || {};
- const totalWeeks = Number(sc.totalWeeks) || 0;
- if (!(totalWeeks >= 1)) throw new Error('/semesterConfig 未返回有效 totalWeeks');
- const startDate = fjpitAlignToMonday(sc.startDate); // 教务给的不一定是周一
- const ts = fjpitParseTimeSlots(await slotPromise);
- console.log('JS: 作息 ' + ts.slots.length + ' 节(剔除休息行 ' + ts.dropped + ')');
- // 3) 逐周课表(限并发;单周失败只记账不中断)
- const weeks = [];
- for (let w = 1; w <= totalWeeks; w++) weeks.push(w);
- const results = await fjpitForEachLimit(weeks, FJPIT_WEEK_CONCURRENCY, async function (w) {
- try {
- const d = await fjpitApiPost('/schedule',
- { semester: semester, week: w, showxxq: showXxq }, token);
- return { week: w, list: Array.isArray(d && d.list) ? d.list : [], ok: true };
- } catch (e) {
- console.warn('JS: 第 ' + w + ' 周抓取失败: ' + e.message);
- return { week: w, list: [], ok: false };
- }
- });
- const entries = [];
- const failedWeeks = [];
- results.forEach(function (r) {
- if (!r.ok) { failedWeeks.push(r.week); return; }
- r.list.forEach(function (rec) {
- const e = fjpitParseEntry(rec, r.week, ts.nodeMap);
- if (e) entries.push(e);
- });
- });
- if (failedWeeks.length > Math.floor(totalWeeks / 2)) {
- throw new Error('过半周次抓取失败(' + failedWeeks.length + '/' + totalWeeks + '):'
- + failedWeeks.join(','));
- }
- const elapsedMs = Date.now() - t0;
- console.log('JS: 取数完成 ' + elapsedMs + 'ms;失败周次 ' + failedWeeks.length
- + ';请求间隔 ' + FJPIT_CUR_GAP_MS + 'ms');
- return {
- entries: entries, timeSlots: ts.slots,
- semesterStartDate: startDate, totalWeeks: totalWeeks,
- failedWeeks: failedWeeks, elapsedMs: elapsedMs
- };
- }
- // ---------- 三、聚合(教师/教室原样透传) ----------
- function fjpitAggregate(entries) {
- const SEP = '\u0001';
- const map = new Map();
- entries.forEach(function (e) {
- const key = [e.name, e.teacher, e.room, e.day, e.start, e.end].join(SEP);
- if (!map.has(key)) {
- map.set(key, {
- name: e.name,
- teacher: e.teacher, // 可能为空字符串,原样保留
- position: e.room, // 可能为空字符串,原样保留
- day: e.day,
- startSection: e.start,
- endSection: e.end,
- weeks: []
- });
- }
- const course = map.get(key);
- if (course.weeks.indexOf(e.week) < 0) course.weeks.push(e.week);
- });
- const list = Array.from(map.values());
- list.forEach(function (c) { c.weeks.sort(function (a, b) { return a - b; }); });
- list.sort(function (a, b) {
- return a.day - b.day || a.startSection - b.startSection || a.name.localeCompare(b.name);
- });
- return list;
- }
- // ---------- 四、保存 ----------
- async function fjpitSaveCourses(courses) {
- await window.shiguangBridgePromise.saveImportedCourses(JSON.stringify(courses, null, 2));
- }
- /** 作息要求编号从 1 起连续,否则不导入(返回 false) */
- async function fjpitSaveTimeSlots(timeSlots) {
- if (!timeSlots.length) return false;
- for (let i = 0; i < timeSlots.length; i++) {
- if (timeSlots[i].number !== i + 1) return false;
- }
- await window.shiguangBridgePromise.savePresetTimeSlots(JSON.stringify(timeSlots));
- return true;
- }
- async function fjpitSaveConfig(semesterStartDate, totalWeeks) {
- const config = { semesterTotalWeeks: totalWeeks };
- if (semesterStartDate) config.semesterStartDate = semesterStartDate;
- await window.shiguangBridgePromise.saveCourseConfig(JSON.stringify(config));
- }
- // ---------- 五、等待登录 ----------
- /** 轮询等待用户登录;页面被整页重载则提前返回 null */
- async function fjpitWaitForLogin(deadlineTs) {
- const bodyRef = document.body;
- let lastTip = 0;
- while (Date.now() < deadlineTs) {
- if (document.body !== bodyRef) return null;
- const token = fjpitGetAccessToken();
- if (token) return token;
- if (Date.now() - lastTip > 60000) { // 每分钟用原生 toast 提醒一次
- lastTip = Date.now();
- fjpitSafeToast('请在页面中登录教务账号,登录后会自动继续导入');
- }
- await fjpitDelay(700);
- }
- return null;
- }
- // ---------- 六、主流程(编排) ----------
- // 按官方推荐的编排模式:只做顺序编排;任一步失败立即 return;
- // notifyTaskCompletion() 只在完全成功后调用。
- /** 确保已登录;未登录则用原生 toast 提示并等待 */
- async function fjpitEnsureLogin() {
- let token = fjpitGetAccessToken();
- if (token) return token;
- fjpitSafeToast('未检测到登录状态,请在页面中登录教务账号');
- token = await fjpitWaitForLogin(Date.now() + FJPIT_LOGIN_WAIT_MS);
- if (!token) token = fjpitGetAccessToken(); // 兜底:前端可能刚把 token 写进 store
- return token;
- }
- /** 聚合 + 保存。课程保存失败会抛出(必须中断);作息与配置尽力而为 */
- async function fjpitSaveAll(data) {
- const courses = fjpitAggregate(data.entries);
- console.log('JS: 原始条目 ' + data.entries.length + ',聚合为 ' + courses.length
- + ' 条课程;开学日期 ' + data.semesterStartDate);
- await fjpitSaveCourses(courses);
- let timeSlotSaved = false;
- try {
- timeSlotSaved = await fjpitSaveTimeSlots(data.timeSlots || []);
- } catch (e) {
- console.warn('JS: 作息时间导入失败: ' + e.message);
- }
- try {
- await fjpitSaveConfig(data.semesterStartDate, data.totalWeeks);
- } catch (e) {
- console.warn('JS: 课表配置保存失败: ' + e.message);
- }
- return { courses: courses, timeSlotSaved: timeSlotSaved };
- }
- /** 汇总公告 —— 本脚本唯一的弹窗 */
- async function fjpitReport(data, saved) {
- const courses = saved.courses;
- const nameSet = {};
- courses.forEach(function (c) { nameSet[c.name] = 1; });
- const summary = [
- '导入完成',
- '课程行数:' + courses.length + '(' + Object.keys(nameSet).length + ' 门课)',
- '学期周数:' + data.totalWeeks,
- '开学日期:' + (data.semesterStartDate || '未取到'),
- '作息时间:' + (saved.timeSlotSaved ? (data.timeSlots || []).length + ' 节' : '未导入'),
- '取数耗时:' + (data.elapsedMs ? ((data.elapsedMs / 1000).toFixed(1) + 's') : '未知'),
- (data.failedWeeks && data.failedWeeks.length)
- ? '失败周次:' + data.failedWeeks.join(',') : '全部周次抓取成功'
- ];
- console.log('JS: ' + summary.join(' | '));
- await window.shiguangBridgePromise.showAlert('导入完成', summary.join('\n'), '好的');
- fjpitSafeToast('导入成功,共 ' + courses.length + ' 条课程');
- }
- /** 编排入口;任一步失败或用户取消即 return */
- async function runImportFlow() {
- console.log('JS: 福信智慧教务课表导入开始(v21)');
- // 0. 环境准备
- if (location.host.indexOf(FJPIT_HOST_KEY) < 0) {
- fjpitSafeToast('请先在福信智慧教务页面登录后再执行导入。');
- return;
- }
- const token = await fjpitEnsureLogin();
- if (!token) {
- fjpitSafeToast('未等到登录状态,请登录后重新点「执行导入」');
- return;
- }
- // 2. 取数(全部走教务接口,不解析页面 HTML)
- let data;
- try {
- data = await fjpitCollectData(token);
- } catch (e) {
- fjpitSafeToast('取数失败,请确认已登录教务系统后重试:' + e.message);
- return;
- }
- if (!data.entries.length) {
- fjpitSafeToast('未取到任何课程,请确认本学期是否有排课。');
- return;
- }
- // 3. 聚合 + 保存
- let saved;
- try {
- saved = await fjpitSaveAll(data);
- } catch (e) {
- fjpitSafeToast('课程保存失败:' + e.message);
- return;
- }
- // 4. 汇总
- await fjpitReport(data, saved);
- // 5. 完全成功,才发结束信号
- window.shiguangBridge.notifyTaskCompletion();
- }
- runImportFlow().catch(function (e) {
- console.error('JS: 导入流程异常', e);
- try { window.shiguangBridge.showToast('导入失败: ' + (e && e.message)); } catch (x) { }
- });
|