import cheerio from "cheerio"; import {FULL_PROGRAM_URL_PREFIX, PROGRAM_LIST_URL, PROGRAM_URL} from "../constants/strings"; import {InfoHelper} from "../index"; import {MOCK_PROGRAM} from "../mocks/program"; import { CourseItemCompletion, CourseSetCompletion, CourseSetFull, CourseState, CourseType, ProgramCompletion, ProgramFull, } from "../models/program/program"; import {getTrimmedData} from "../utils/cheerio"; import {ProgramError} from "../utils/error"; import {uFetch} from "../utils/network"; import {systemMessage} from "./basics"; import {roamingWrapper, roamingWrapperWithMocks} from "./core"; export const getDegreeProgramCompletion = async (helper: InfoHelper) => roamingWrapperWithMocks( helper, "default", "287C0C6D90ABB364CD5FDF1495199962", () => uFetch(PROGRAM_URL).then((str) => { const program: ProgramCompletion = { completedCredit: 0, compulsoryCredit: 0, restrictedCredit: 0, electiveCredit: 0, duplicatedCourse: [], courseSet: [] }; if (str.includes(systemMessage)) { // 强制重新登陆 throw new ProgramError(systemMessage); } const getSubstr = (base: string, prefix: string, suffix: string): string => { const lowerBound = base.indexOf(prefix) + prefix.length; const upperBound = base.indexOf(suffix, lowerBound); return base.substring(lowerBound, upperBound); }; // Parse basic info const basicInfoStr = getSubstr(str, "方案内实际完成", "学分只计一次"); program.completedCredit = parseInt( getSubstr(basicInfoStr, "总学分:", ""), 10 ); program.compulsoryCredit = parseInt( getSubstr(basicInfoStr, "其中必修完成总学分:", ""), 10 ); program.restrictedCredit = parseInt( getSubstr(basicInfoStr, "限选完成总学分:", ""), 10 ); program.electiveCredit = parseInt( getSubstr(basicInfoStr, "任选(方案内)完成总学分:", ""), 10 ); program.duplicatedCourse = getSubstr(basicInfoStr, "重复课程:", "属于多个课组") .replace(/[\t{"}]/g, "") .split("、"); const excludedCredit = parseInt( getSubstr(str, "本科生已修培养方案外课程完成总学分:", ""), 10 ); const mayFailParseInt = (x: string): number | undefined => { if (x === "") { return undefined; } else { return parseInt(x, 10); } }; const mayFailParseFloat = (x: string): number | undefined => { const f = parseFloat(x); if (Number.isNaN(f)) { return undefined; } else { return f; } }; /** * level 表示该课程在表格中的层级 * 每个课程属性部分的第一个课程 - 2 * 每个课组的第一个课程 - 1 * 其余课程 - 0 */ const parseCourse = (element: any, level: 2 | 1 | 0): CourseItemCompletion | undefined => { let courseName = getTrimmedData(element, [level + 1, 1, 0]); if (courseName === "") { // 未修完的课程会显示蓝名,导致标签层次不同 courseName = getTrimmedData(element, [level + 1, 1, 1, 0]); } if (courseName === "") { // 两次尝试均未获取合法课程名称 return undefined; } const gradeOrState = getTrimmedData(element, [level + 3, 1, 0]); let state = CourseState.COMPLETED; let grade: string | undefined = gradeOrState; if (gradeOrState === "未修") { state = CourseState.NOT_COMPLETED; grade = undefined; } else if (gradeOrState === "选课") { state = CourseState.ELECTED; grade = undefined; } else if (gradeOrState === "W" || gradeOrState === "F" || gradeOrState === "I") { state = CourseState.NOT_COMPLETED; grade = gradeOrState; } const course: CourseItemCompletion = { id: getTrimmedData(element, [level, 1, 0]), name: courseName, credit: parseInt(getTrimmedData(element, [level + 2, 1, 0]), 10), point: mayFailParseFloat(getTrimmedData(element, [level + 4, 0])), grade: grade, state: state, }; return course; }; /** * level 表示该课程在表格中的层级 * 每个课程属性部分的第一个课程 - 1 * 每个课组的第一个课程 - 0 */ const parseCourseSet = (element: any, level: 1 | 0): CourseSetCompletion => { let name = getTrimmedData(element, [level, 1, 1, 2]); if (name === "") { // 未修完的课组会显示红名,导致标签层次不同 name = getTrimmedData(element, [level, 1, 1, 1, 1]); } const courseSet: CourseSetCompletion = { setName: name, type: courseTypeNow as CourseType, course: [], // 稍后填充 requiredCredit: mayFailParseInt(getTrimmedData(element, [level + 6, 1, 0])), completedCredit: mayFailParseInt(getTrimmedData(element, [level + 7, 1, 0])), requiredCourseNum: mayFailParseInt(getTrimmedData(element, [level + 8, 1, 0])), completedCourseNum: mayFailParseInt(getTrimmedData(element, [level + 9, 1, 0])), fullCompleted: getTrimmedData(element, [level + 10, 1, 1, 0]) === "是", }; return courseSet; }; let courseTypeNow = -1; let illegalCourseLevelFlag = false; cheerio(".table-striped tr", str) .slice(1) // 跳过表头 .each((index, element) => { if (element.type === "tag") { if (element.children.length === 25) { // 每个课程属性部分的第一个课程 courseTypeNow += 1; // 递增课程属性 const courseSet = parseCourseSet(element, 1); const course = parseCourse(element, 2); if (course !== undefined) { courseSet.course.push(course); } program.courseSet.push(courseSet); } else if (element.children.length === 23) { // 每个课组的第一个课程 const courseSet = parseCourseSet(element, 0); const course = parseCourse(element, 1); if (course !== undefined) { courseSet.course.push(course); } program.courseSet.push(courseSet); } else if (element.children.length === 11) { // 其余课程 const course = parseCourse(element, 0); if (course !== undefined) { program.courseSet[program.courseSet.length - 1].course.push(course); } } else if (element.children.length === 21) { // 培养方案外课程 const idStr = getTrimmedData(element, [0, 0, 0]); if (idStr.length > 0) { const credit = parseInt(getTrimmedData(element, [3, 0, 0]), 10); const grade = getTrimmedData(element, [5, 1, 0]); const course: CourseItemCompletion = { id: idStr, name: getTrimmedData(element, [2, 0, 0]), credit: credit, point: mayFailParseFloat(getTrimmedData(element, [6, 0, 0])), grade: grade, state: CourseState.COMPLETED, }; program.courseSet[program.courseSet.length - 1].course.push(course); } else { const courseSet: CourseSetCompletion = { setName: "方案外课程", type: CourseType.EXCLUDED, course: [], // 稍后填充 completedCredit: excludedCredit, fullCompleted: false, }; program.courseSet.push(courseSet); } } else { illegalCourseLevelFlag = true; } } }); if (illegalCourseLevelFlag) { throw new ProgramError("检测到未知层级课程"); } return program; }), MOCK_PROGRAM, ); export const getFullDegreeProgram = async ( helper: InfoHelper, degreeId?: number, skippedSet?: string[], ) => { let id: number = degreeId === undefined ? -1 : degreeId; if (degreeId === undefined) { id = await roamingWrapper( helper, "default", "287C0C6D90ABB364CD5FDF1495199962", () => uFetch(PROGRAM_LIST_URL).then((str) => { if (str.includes(systemMessage)) { // 强制重新登陆 throw new ProgramError(systemMessage); } const getSubstr = (base: string, prefix: string, suffix: string): string => { const lowerBound = base.indexOf(prefix) + prefix.length; const upperBound = base.indexOf(suffix, lowerBound); return base.substring(lowerBound, upperBound); }; return parseInt(getSubstr(str, "m=pyfakzFrame&fajhh=", "&"), 10); }), ); } return roamingWrapperWithMocks( helper, "default", "287C0C6D90ABB364CD5FDF1495199962", () => uFetch(FULL_PROGRAM_URL_PREFIX + id).then((str) => { if (str.includes(systemMessage)) { // 强制重新登陆 throw new ProgramError(systemMessage); } let skippedFlag = false; let courseSetName = ""; let illegalCourseLevelFlag = false; const program: ProgramFull = { courseSet: [], }; cheerio("#content_1 table tbody tr.trr2", str) .each((index, element) => { if (element.type === "tag") { if (element.children.length === 11) { const name = getTrimmedData(element, [0, 1, 0]); if (skippedSet !== undefined && skippedSet.includes(name)) { skippedFlag = true; courseSetName = "[SKIPPED]"; } else { skippedFlag = false; courseSetName = name; const setTypeMap: Record = { "必修": CourseType.COMPULSORY, "限选": CourseType.RESTRICTED, "任选": CourseType.ELECTIVE, }; const courseSet: CourseSetFull = { setName: courseSetName, type: setTypeMap[getTrimmedData(element, [1, 1, 0])], course: [{ id: getTrimmedData(element, [2, 1, 0]), name: getTrimmedData(element, [3, 1, 1, 0]), credit: parseInt(getTrimmedData(element, [4, 1, 0]), 10), }], }; program.courseSet.push(courseSet); } } else if (element.children.length === 7) { if (!skippedFlag) { program.courseSet[program.courseSet.length - 1].course.push({ id: getTrimmedData(element, [0, 1, 0]), name: getTrimmedData(element, [1, 1, 1, 0]), credit: parseInt(getTrimmedData(element, [2, 1, 0]), 10), }); } } else { illegalCourseLevelFlag = true; } } }); if (illegalCourseLevelFlag) { throw new ProgramError("检测到未知层级课程"); } return program; }), MOCK_PROGRAM, // TODO: Mock! ); };