Просмотр исходного кода

Merge pull request #478 from lhgr/pending

星河欲转 4 недель назад
Родитель
Сommit
d1906ba6db
1 измененных файлов с 330 добавлено и 71 удалено
  1. 330 71
      resources/CQUT/cqut_01.js

+ 330 - 71
resources/CQUT/cqut_01.js

@@ -3,32 +3,59 @@
  * author: Dawn Drizzle
  */
 const API_BASE = 'https://timetable-cfc.cqut.edu.cn/api/courseSchedule';
+const MAX_WEEK_REQUEST_CONCURRENCY = 5;
+const REQUEST_RETRY_COUNT = 1;
+const RETRY_DELAY_MS = 300;
+
+const wait = (duration) => new Promise((resolve) => setTimeout(resolve, duration));
 
 // 仅允许在课表站点内执行,避免跨站点误触发
 const checkLogin = () => window.location.hostname === 'timetable-cfc.cqut.edu.cn';
 
-// 统一的接口请求封装:POST + JSON + 携带 Cookie,并在失败时提示原因
+// 统一的接口请求封装:POST + JSON + 携带 Cookie;网络错误、限流和服务端错误会重试一次
 const baseFetch = async (path, body, description) => {
-    const response = await fetch(`${API_BASE}/${path}`, {
-        method: 'POST',
-        credentials: 'include',
-        headers: {
-            'Content-Type': 'application/json',
-        },
-        body: body === undefined ? undefined : JSON.stringify(body),
-    });
+    const requestBody = body === undefined ? undefined : JSON.stringify(body);
+
+    for (let attempt = 0; attempt <= REQUEST_RETRY_COUNT; attempt++) {
+        let response;
+
+        try {
+            response = await fetch(`${API_BASE}/${path}`, {
+                method: 'POST',
+                credentials: 'include',
+                headers: {
+                    'Content-Type': 'application/json',
+                },
+                body: requestBody,
+            });
+        } catch (error) {
+            if (attempt < REQUEST_RETRY_COUNT) {
+                await wait(RETRY_DELAY_MS * (attempt + 1));
+                continue;
+            }
+
+            throw new Error(`获取${description}失败: ${error.message}`);
+        }
 
-    if (!response.ok) {
-        window.shiguangBridge.showToast(`获取${description}失败,请确认已登录后重试`);
-        throw new Error(`获取${description}失败: ${response.status} ${response.statusText}`);
-    }
+        if (!response.ok) {
+            const retryable = response.status === 429 || response.status >= 500;
 
-    try {
-        return await response.json();
-    } catch (error) {
-        window.shiguangBridge.showToast(`解析${description}失败,请确认当前页面登录状态`);
-        throw new Error(`解析${description}失败: ${error.message}`);
+            if (retryable && attempt < REQUEST_RETRY_COUNT) {
+                await wait(RETRY_DELAY_MS * (attempt + 1));
+                continue;
+            }
+
+            throw new Error(`获取${description}失败: ${response.status} ${response.statusText}`);
+        }
+
+        try {
+            return await response.json();
+        } catch (error) {
+            throw new Error(`解析${description}失败: ${error.message}`);
+        }
     }
+
+    throw new Error(`获取${description}失败`);
 };
 
 // 获取当前登录用户信息(包含 username、校区等)
@@ -48,15 +75,142 @@ const getWeekEvents = async (userID, weekNum, yearTerm, description) => await ba
     description,
 );
 
-// 将接口的节次时间转换为统一结构,并按节次序号升序排序
-const parseTimeSlots = (timeSlots) => timeSlots
-    .slice()
-    .sort((left, right) => Number(left.sessionNum) - Number(right.sessionNum))
-    .map((timeSlot) => ({
-        number: Number(timeSlot.sessionNum) || 0,
-        startTime: timeSlot.startTime ?? '',
-        endTime: timeSlot.endTime ?? '',
-    }));
+// 使用固定数量的工作协程处理每周请求,避免短时间内向教务接口发出过多请求
+const mapWithConcurrency = async (items, concurrency, mapper) => {
+    const results = new Array(items.length);
+    let nextIndex = 0;
+    const workerCount = Math.min(concurrency, items.length);
+    const workers = Array.from({ length: workerCount }, async () => {
+        while (nextIndex < items.length) {
+            const currentIndex = nextIndex;
+            nextIndex += 1;
+            results[currentIndex] = await mapper(items[currentIndex], currentIndex);
+        }
+    });
+
+    await Promise.all(workers);
+    return results;
+};
+
+const normalizePositiveIntegerList = (values) => [...new Set(
+    (Array.isArray(values) ? values : [])
+        .map(Number)
+        .filter((value) => Number.isInteger(value) && value > 0)
+)].sort((left, right) => left - right);
+
+const normalizeWeekList = (weekList) => normalizePositiveIntegerList(weekList);
+
+// 检查接口响应中的学期是否与用户选择一致,防止服务端忽略 yearTerm 参数
+const assertMatchingYearTerm = (weekData, expectedYearTerm, description, required = false) => {
+    const actualYearTerm = String(weekData?.yearTerm ?? '').trim();
+
+    if (!actualYearTerm) {
+        if (required) {
+            throw new Error(`${description}缺少学期标识`);
+        }
+
+        return;
+    }
+
+    if (actualYearTerm !== expectedYearTerm) {
+        throw new Error(`${description}返回了错误学期: ${actualYearTerm}`);
+    }
+};
+
+// 将接口中的学期标识转换为更易读的选项文本
+const formatYearTerm = (yearTerm) => {
+    const value = String(yearTerm ?? '').trim();
+    const match = value.match(/^(\d{4})-(\d{4})-(\d+)$/);
+
+    return match ? `${match[1]}-${match[2]}学年 第${match[3]}学期` : value;
+};
+
+// 从当前周接口返回值中读取可用学期,并让用户选择要导入的学期
+const selectYearTerm = async (weekData) => {
+    const currentYearTerm = String(weekData?.yearTerm ?? '').trim();
+    const yearTermList = Array.isArray(weekData?.yearTermList)
+        ? weekData.yearTermList.map((item) => String(item ?? '').trim()).filter(Boolean)
+        : [];
+    const yearTerms = [...new Set(yearTermList)];
+
+    // 兼容接口暂未返回 yearTermList 的情况,仍允许导入当前学期
+    if (currentYearTerm && !yearTerms.includes(currentYearTerm)) {
+        yearTerms.unshift(currentYearTerm);
+    }
+
+    if (yearTerms.length === 0) {
+        throw new Error('学期列表为空');
+    }
+
+    const currentIndex = yearTerms.indexOf(currentYearTerm);
+    const selectedIndex = await window.shiguangBridgePromise.showSingleSelection(
+        '选择学期',
+        JSON.stringify(yearTerms.map(formatYearTerm)),
+        currentIndex >= 0 ? currentIndex : 0,
+    );
+
+    if (selectedIndex === null || Number(selectedIndex) === -1) {
+        window.shiguangBridge.showToast('已取消课程导入');
+        return null;
+    }
+
+    const normalizedIndex = Number(selectedIndex);
+
+    if (!Number.isInteger(normalizedIndex) || normalizedIndex < 0 || normalizedIndex >= yearTerms.length) {
+        throw new Error(`无效的学期选项索引: ${selectedIndex}`);
+    }
+
+    return yearTerms[normalizedIndex];
+};
+
+const normalizeTime = (value) => {
+    const match = String(value ?? '').trim().match(/^(\d{1,2}):(\d{2})$/);
+
+    if (!match) {
+        return null;
+    }
+
+    const hour = Number(match[1]);
+    const minute = Number(match[2]);
+
+    if (hour < 0 || hour > 23 || minute < 0 || minute > 59) {
+        return null;
+    }
+
+    return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
+};
+
+const timeToMinutes = (value) => {
+    const [hour, minute] = value.split(':').map(Number);
+    return hour * 60 + minute;
+};
+
+// 将接口的节次时间转换为统一结构,过滤无效项、按节次去重并升序排序
+const parseTimeSlots = (timeSlots) => {
+    const parsedTimeSlots = new Map();
+
+    for (const timeSlot of Array.isArray(timeSlots) ? timeSlots : []) {
+        const number = Number(timeSlot?.sessionNum);
+        const startTime = normalizeTime(timeSlot?.startTime);
+        const endTime = normalizeTime(timeSlot?.endTime);
+
+        if (
+            !Number.isInteger(number)
+            || number <= 0
+            || !startTime
+            || !endTime
+            || timeToMinutes(endTime) <= timeToMinutes(startTime)
+        ) {
+            continue;
+        }
+
+        if (!parsedTimeSlots.has(number)) {
+            parsedTimeSlots.set(number, { number, startTime, endTime });
+        }
+    }
+
+    return [...parsedTimeSlots.values()].sort((left, right) => left.number - right.number);
+};
 
 // 推算学期开始日期(YYYY-MM-DD):使用 weekDayList 第一条的月/日 + yearTerm 中的学年信息
 const parseSemesterStartDate = (yearTerm, weekDayList) => {
@@ -66,22 +220,25 @@ const parseSemesterStartDate = (yearTerm, weekDayList) => {
         return null;
     }
 
-    const [startYear, endYear, termPart] = String(yearTerm).split('-');
-    const [month, day] = String(firstWeekDate).split('/').map(Number);
+    const yearTermMatch = String(yearTerm).match(/^(\d{4})-(\d{4})-([12])$/);
+    const weekDateMatch = String(firstWeekDate).trim().match(/^(\d{1,2})\/(\d{1,2})$/);
 
-    if (!startYear || !endYear || !termPart || Number.isNaN(month) || Number.isNaN(day)) {
+    if (!yearTermMatch || !weekDateMatch) {
         return null;
     }
 
+    const [, startYear, endYear, termPart] = yearTermMatch;
+    const month = Number(weekDateMatch[1]);
+    const day = Number(weekDateMatch[2]);
     const year = termPart === '1' ? Number(startYear) : Number(endYear);
-
-    if (Number.isNaN(year)) {
-        return null;
-    }
-
     const date = new Date(Date.UTC(year, month - 1, day));
 
-    if (Number.isNaN(date.getTime())) {
+    if (
+        Number.isNaN(date.getTime())
+        || date.getUTCFullYear() !== year
+        || date.getUTCMonth() !== month - 1
+        || date.getUTCDate() !== day
+    ) {
         return null;
     }
 
@@ -90,18 +247,44 @@ const parseSemesterStartDate = (yearTerm, weekDayList) => {
 
 // 将接口的 event 解析为课程结构(节次、星期、周次等)
 const parseCourse = (event) => {
-    const sessionList = (event.sessionList ?? []).map(Number).filter((session) => !Number.isNaN(session));
-    const startSection = Number(event.sessionStart) || sessionList[0] || 0;
-    const endSection = sessionList[sessionList.length - 1] || startSection + (Number(event.sessionLast) || 1) - 1;
+    if (!event || typeof event !== 'object') {
+        return null;
+    }
+
+    const sessionList = normalizePositiveIntegerList(event.sessionList);
+    const declaredStartSection = Number(event.sessionStart);
+    const startSection = Number.isInteger(declaredStartSection) && declaredStartSection > 0
+        ? declaredStartSection
+        : sessionList[0];
+    const declaredDuration = Number(event.sessionLast);
+    const duration = Number.isInteger(declaredDuration) && declaredDuration > 0 ? declaredDuration : 1;
+    const endSection = sessionList[sessionList.length - 1] ?? startSection + duration - 1;
+    const name = String(event.eventName ?? '').trim();
+    const day = Number(event.weekDay);
+    const weeks = normalizeWeekList(event.weekList);
+
+    if (
+        !name
+        || !Number.isInteger(day)
+        || day < 1
+        || day > 7
+        || !Number.isInteger(startSection)
+        || startSection <= 0
+        || !Number.isInteger(endSection)
+        || endSection < startSection
+        || weeks.length === 0
+    ) {
+        return null;
+    }
 
     return {
-        name: event.eventName ?? '',
-        teacher: event.memberName ?? '',
-        position: event.address ?? '',
-        day: Number(event.weekDay) || 0,
+        name,
+        teacher: String(event.memberName ?? '').trim(),
+        position: String(event.address ?? '').trim(),
+        day,
         startSection,
         endSection,
-        weeks: (event.weekList ?? []).map(Number).filter((week) => !Number.isNaN(week)),
+        weeks,
     };
 };
 
@@ -111,6 +294,11 @@ const mergeCourses = (events) => {
 
     for (const event of events) {
         const course = parseCourse(event);
+
+        if (!course) {
+            continue;
+        }
+
         const key = [
             course.name,
             course.teacher,
@@ -132,17 +320,37 @@ const mergeCourses = (events) => {
     return [...mergedCourses.values()];
 };
 
-// 将解析后的结构写入 App(配置、课程列表、节次时间表)
-const saveSchedule = (parsedSchedule) => Promise.all([
-    window.shiguangBridgePromise.saveCourseConfig(JSON.stringify(parsedSchedule.courseConfig)),
-    window.shiguangBridgePromise.saveImportedCourses(JSON.stringify(parsedSchedule.courses)),
-    window.shiguangBridgePromise.savePresetTimeSlots(JSON.stringify(parsedSchedule.timeSlots)),
-]);
+const saveBridgeData = async (description, saveAction) => {
+    try {
+        const result = await saveAction();
 
-// 主流程:校验页面 → 拉取用户/校区 → 拉取节次/当前学期信息 → 拉取全学期每周课程 → 合并保存
-(async () => {
+        if (result !== true) {
+            throw new Error(`返回值异常: ${String(result)}`);
+        }
+    } catch (error) {
+        throw new Error(`${description}保存失败: ${error?.message || String(error)}`);
+    }
+};
+
+// 顺序保存,并将课程放在最后,避免配置或作息保存失败时提前覆盖现有课程
+const saveSchedule = async (parsedSchedule) => {
+    await saveBridgeData(
+        '课表配置',
+        () => window.shiguangBridgePromise.saveCourseConfig(JSON.stringify(parsedSchedule.courseConfig)),
+    );
+    await saveBridgeData(
+        '节次时间',
+        () => window.shiguangBridgePromise.savePresetTimeSlots(JSON.stringify(parsedSchedule.timeSlots)),
+    );
+    await saveBridgeData(
+        '课程数据',
+        () => window.shiguangBridgePromise.saveImportedCourses(JSON.stringify(parsedSchedule.courses)),
+    );
+};
+
+// 主流程:校验页面 → 拉取用户/校区 → 获取并选择学期 → 拉取所选学期每周课程 → 合并保存
+const runImportFlow = async () => {
     if (!checkLogin()) {
-        window.shiguangBridge.showToast('请先打开重庆理工大学课表页面并登录');
         throw new Error('当前不在重庆理工大学课表页面');
     }
 
@@ -151,39 +359,90 @@ const saveSchedule = (parsedSchedule) => Promise.all([
     const campusName = userInfo?.userCustomSetting?.campusName;
 
     if (!userID || !campusName) {
-        window.shiguangBridge.showToast('未获取到完整的用户信息,请确认已登录');
         throw new Error('用户信息不完整');
     }
 
-    const [timeSlotData, currentWeekData] = await Promise.all([
+    const [timeSlotData, semesterOverview] = await Promise.all([
         getCampusTimeInfo(campusName),
-        getWeekEvents(userID, null, null, '当前学期信息'),
+        getWeekEvents(userID, null, null, '学期信息'),
     ]);
 
-    const yearTerm = currentWeekData?.yearTerm;
-    const weekList = Array.isArray(currentWeekData?.weekList) ? currentWeekData.weekList : [];
-    const currentWeekNum = currentWeekData?.weekNum;
-    const semesterStartDate = parseSemesterStartDate(yearTerm, currentWeekData?.weekDayList);
+    const yearTerm = await selectYearTerm(semesterOverview);
+
+    if (yearTerm === null) {
+        return;
+    }
+
+    // 选择当前学期时复用首次请求;选择其他学期时重新获取其周次和日期信息
+    const selectedWeekData = String(semesterOverview?.yearTerm ?? '').trim() === yearTerm
+        ? semesterOverview
+        : await getWeekEvents(userID, null, yearTerm, `${formatYearTerm(yearTerm)}信息`);
+    assertMatchingYearTerm(selectedWeekData, yearTerm, '所选学期信息', true);
+    const weekList = normalizeWeekList(selectedWeekData?.weekList);
+    const timeSlots = parseTimeSlots(timeSlotData);
+
+    if (weekList.length === 0) {
+        throw new Error(`学期信息不完整: ${yearTerm}`);
+    }
+
+    if (timeSlots.length === 0) {
+        throw new Error('未获取到有效的节次时间');
+    }
 
-    if (!yearTerm || weekList.length === 0) {
-        window.shiguangBridge.showToast('未获取到当前学期信息');
-        throw new Error('当前学期信息不完整');
+    // semesterStartDate 必须取自第一周;首次接口可能返回的是当前周,不能直接使用其中的日期
+    const firstWeekNum = weekList[0];
+    const selectedWeekNum = Number(selectedWeekData?.weekNum);
+    const firstWeekData = selectedWeekNum === firstWeekNum
+        ? selectedWeekData
+        : await getWeekEvents(userID, String(firstWeekNum), yearTerm, `第${firstWeekNum}周课程`);
+    assertMatchingYearTerm(firstWeekData, yearTerm, `第${firstWeekNum}周课程`);
+    const semesterStartDate = parseSemesterStartDate(yearTerm, firstWeekData?.weekDayList);
+    const preloadedWeekData = new Map([[String(firstWeekNum), firstWeekData]]);
+
+    if (weekList.includes(selectedWeekNum)) {
+        preloadedWeekData.set(String(selectedWeekNum), selectedWeekData);
     }
 
-    const weekResults = await Promise.all(
-        weekList.map((weekNum) => String(weekNum) === String(currentWeekNum)
-            ? currentWeekData
-            : getWeekEvents(userID, String(weekNum), yearTerm, `第${weekNum}周课程`))
+    const weekResults = await mapWithConcurrency(
+        weekList,
+        MAX_WEEK_REQUEST_CONCURRENCY,
+        (weekNum) => preloadedWeekData.get(String(weekNum))
+            ?? getWeekEvents(userID, String(weekNum), yearTerm, `第${weekNum}周课程`),
     );
 
+    weekResults.forEach((result, index) => {
+        assertMatchingYearTerm(result, yearTerm, `第${weekList[index]}周课程`);
+    });
+
+    const events = weekResults.flatMap((result) => Array.isArray(result?.eventList) ? result.eventList : []);
+    const courses = mergeCourses(events);
+
+    if (courses.length === 0) {
+        window.shiguangBridge.showToast(`${formatYearTerm(yearTerm)}未找到课程,未修改现有课表`);
+        return;
+    }
+
     await saveSchedule({
         courseConfig: {
             semesterStartDate,
-            semesterTotalWeeks: weekList.length,
+            semesterTotalWeeks: weekList[weekList.length - 1],
         },
-        timeSlots: parseTimeSlots(timeSlotData),
-        courses: mergeCourses(weekResults.flatMap((result) => result?.eventList ?? [])),
+        timeSlots,
+        courses,
     });
 
     window.shiguangBridge.notifyTaskCompletion();
-})();
+};
+
+// 所有非用户取消类错误都在入口统一反馈,避免未处理的 Promise 拒绝
+const runImportFlowSafely = async () => {
+    try {
+        await runImportFlow();
+    } catch (error) {
+        const message = error?.message || String(error);
+        console.error(`[课程导入失败] ${message}`);
+        window.shiguangBridge.showToast(`课程导入失败:${message}`);
+    }
+};
+
+runImportFlowSafely();