|
| 1 | +import { DayOfWeek, Term } from '../models/shared' |
| 2 | +import { Course, ClassArrangement } from '../models/Course' |
| 3 | +import { WeekType } from '@/models/shared' |
| 4 | +import { ZjuamService } from '../interop/zjuam' |
| 5 | +import { requestCredential } from '@/interop/credential' |
| 6 | + |
| 7 | +/**课程表中的课程信息,无学分、考试 */ |
| 8 | +type CourseInSchedule = Omit<Course, 'credit' | 'exams'> |
| 9 | +type RawCourseResp = { |
| 10 | + kbList: { |
| 11 | + kcb: string |
| 12 | + dsz: string |
| 13 | + djj: string |
| 14 | + xqj: number |
| 15 | + xxq: string |
| 16 | + xkkh: string |
| 17 | + skcd: string |
| 18 | + }[] |
| 19 | + xnm: string |
| 20 | +} |
| 21 | + |
| 22 | +export class CourseSpider { |
| 23 | + private zjuamService = new ZjuamService( |
| 24 | + { service: 'http://zdbk.zju.edu.cn/jwglxt/xtgl/login_ssologin.html' }, |
| 25 | + 60 * 30, |
| 26 | + ) |
| 27 | + constructor() {} |
| 28 | + |
| 29 | + /**查询指定年份区间的课表。未查短学期的课,未查实践课。 */ |
| 30 | + public async getCourses( |
| 31 | + /** 起始学年(靠前的,如2024 - 2025请传2024) */ |
| 32 | + xnmStart: number, |
| 33 | + /** 结束学年(靠前的,如2024 - 2025请传2024)*/ |
| 34 | + xnmEnd: number, |
| 35 | + ): Promise<CourseInSchedule[]> { |
| 36 | + const { username: zjuId } = await requestCredential(this.zjuamService) |
| 37 | + const cMap = new Map<string, CourseInSchedule[]>() |
| 38 | + for (let curYear = xnmStart; curYear <= xnmEnd; curYear++) { |
| 39 | + const semesters = [ |
| 40 | + { xqm: '1|秋', xqmmc: '秋' }, |
| 41 | + { xqm: '1|冬', xqmmc: '冬' }, |
| 42 | + { xqm: '2|春', xqmmc: '春' }, |
| 43 | + { xqm: '2|夏', xqmmc: '夏' }, |
| 44 | + ] |
| 45 | + for (const { xqm, xqmmc } of semesters) { |
| 46 | + const params = new URLSearchParams({ |
| 47 | + xnm: `${curYear}-${curYear + 1}`, |
| 48 | + xqm, |
| 49 | + xqmmc, |
| 50 | + xxqf: '0', |
| 51 | + xxfs: '0', |
| 52 | + }) |
| 53 | + const response = await this.zjuamService.nxFetch.postUrlEncoded( |
| 54 | + `http://zdbk.zju.edu.cn/jwglxt/kbcx/xskbcx_cxXsKb.html?gnmkdm=N253508&su=${zjuId}`, |
| 55 | + { body: params }, |
| 56 | + ) |
| 57 | + |
| 58 | + const { xnm: respXnm, kbList } = |
| 59 | + (await response.json()) as RawCourseResp |
| 60 | + for (const { kcb, dsz, djj, xqj, xxq, xkkh, skcd } of kbList) { |
| 61 | + const kcbItem = kcb.split('<br>') |
| 62 | + const name = kcbItem[0] |
| 63 | + const teacher = kcbItem[2] |
| 64 | + const location = kcbItem[3].replace(/zwf.*/, '').trim() |
| 65 | + |
| 66 | + const termIdMap = { |
| 67 | + 春: Term.Spring, |
| 68 | + 夏: Term.Summer, |
| 69 | + 秋: Term.Autumn, |
| 70 | + 冬: Term.Winter, |
| 71 | + 短: Term.Short, |
| 72 | + } |
| 73 | + let termId = 0 |
| 74 | + for (const xxqChar of xxq) |
| 75 | + if (xxqChar in termIdMap) |
| 76 | + termId |= termIdMap[xxqChar as keyof typeof termIdMap] |
| 77 | + else throw new Error('学期匹配失败') |
| 78 | + |
| 79 | + cMap.pushValue(xkkh, { |
| 80 | + semester: { |
| 81 | + year: Number(respXnm.split('-')[0]), |
| 82 | + term: termId, |
| 83 | + }, |
| 84 | + id: xkkh, |
| 85 | + name: name, |
| 86 | + teacherName: teacher, |
| 87 | + classes: [ |
| 88 | + { |
| 89 | + weekType: dsz === '0' ? 'odd' : dsz === '1' ? 'even' : 'every', |
| 90 | + dayOfWeek: xqj as DayOfWeek, |
| 91 | + startSection: Number(djj), |
| 92 | + sectionCount: Number(skcd), |
| 93 | + location: location, |
| 94 | + }, |
| 95 | + ], |
| 96 | + }) |
| 97 | + } |
| 98 | + } |
| 99 | + } |
| 100 | + return [...cMap.values()].map((courses) => ({ |
| 101 | + ...courses[0], // 首项必定存在,课程名称、教师等均取自首项 |
| 102 | + classes: this.mergeClasses(courses.map((c) => c.classes).flat(1)), |
| 103 | + })) |
| 104 | + } |
| 105 | + |
| 106 | + /** |
| 107 | + * 合并课程安排。 |
| 108 | + * 对于地点、周数完全相同的,合并至上课节数不相交,连续的节数也合并为一项; |
| 109 | + * 地点/周数不一样的不合并。 |
| 110 | + */ |
| 111 | + private mergeClasses(classes: ClassArrangement[]): ClassArrangement[] { |
| 112 | + const weekMap = new Map<WeekType, Map<DayOfWeek, Map<string, number>>>() |
| 113 | + classes.forEach((c) => { |
| 114 | + const { weekType, dayOfWeek, location, startSection, sectionCount } = c |
| 115 | + const dayMap = weekMap.ensure(weekType, () => new Map()) |
| 116 | + const locMap = dayMap.ensure(dayOfWeek, () => new Map()) |
| 117 | + /**把已有的位域(默认0)和当前的startSection、sectionCount段进行合并 */ |
| 118 | + function mergeSection(prevSections = 0) { |
| 119 | + for (let sec = startSection; sec < startSection + sectionCount; sec++) |
| 120 | + prevSections |= 0b1 << sec |
| 121 | + //示例:第1、2、4节有课,位域应为0b10110(最低位保留) |
| 122 | + return prevSections |
| 123 | + } |
| 124 | + locMap.ensure(location, mergeSection, mergeSection) |
| 125 | + }) |
| 126 | + return [...weekMap] |
| 127 | + .map(([week, dayMap]) => |
| 128 | + [...dayMap].map(([day, locMap]) => |
| 129 | + [...locMap].map(([loc, secFlags]) => { |
| 130 | + const sections = [] as ClassArrangement[] |
| 131 | + secFlags >>= 1 //最低位保留,右移掉以减少一次循环 |
| 132 | + let curSec = 1, |
| 133 | + startSec = 0, |
| 134 | + secCount = 0 |
| 135 | + /**把当前的startSec、secCount以及其它变量合成为ClassArrangement并加到数组中 */ |
| 136 | + function finishCurSection() { |
| 137 | + sections.push({ |
| 138 | + weekType: week, |
| 139 | + dayOfWeek: day, |
| 140 | + location: loc, |
| 141 | + startSection: startSec, |
| 142 | + sectionCount: secCount, |
| 143 | + }) |
| 144 | + } |
| 145 | + while (secFlags != 0) { |
| 146 | + const curSecValid = Boolean(secFlags & 0b1) |
| 147 | + if (curSecValid) { |
| 148 | + //当前sec有课 |
| 149 | + if (startSec === 0) startSec = curSec |
| 150 | + secCount++ |
| 151 | + } else if (startSec !== 0) { |
| 152 | + finishCurSection() |
| 153 | + startSec = 0 |
| 154 | + secCount = 0 |
| 155 | + } |
| 156 | + secFlags >>= 1 |
| 157 | + curSec++ |
| 158 | + } |
| 159 | + if (startSec != 0) finishCurSection() |
| 160 | + return sections |
| 161 | + }), |
| 162 | + ), |
| 163 | + ) |
| 164 | + .flat(3) |
| 165 | + } |
| 166 | +} |
0 commit comments