Skip to content

Commit 6d16330

Browse files
zjukunkx6eull
andauthored
zdbk课表爬虫 (x6eull#2)
Co-authored-by: x6eull <[email protected]>
1 parent f911832 commit 6d16330

File tree

8 files changed

+218
-7
lines changed

8 files changed

+218
-7
lines changed

src/interop/env.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
1+
import '../utils'
2+
13
export const isNode = !import.meta.env
24
if (isNode)
35
// 当且仅当node环境下,手动加载环境变量
46
await import('dotenv').then(({ config }) => {
5-
;([] as string[])
6-
.concat(...['.env', '.env.development'].map((f) => [f, f + '.local']))
7+
;['.env', '.env.development']
8+
.map((f) => [f, f + '.local'])
9+
.flat(1)
710
.forEach((file) => {
811
const err = config({ path: file }).error as
912
| (Error & { code?: string })

src/interop/fetch.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { CapacitorCookies, CapacitorHttp } from '@capacitor/core'
22
import { appPlatform } from '.'
33
import { isNode } from './env'
4-
import '../utils/extendHeaders'
54

65
let cookieJar: import('tough-cookie').CookieJar | null = null
76
// node环境不会自动保存cookie,手动跟踪

src/models/Course.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { DayOfWeek, Semester, WeekOfSemester } from './shared'
1+
import { DayOfWeek, Semester, WeekType } from './shared'
22

33
/**课程信息,对于同一课程代码,多次选课(弃修、重修)为不同的实例。 */
44
export interface Course {
@@ -20,9 +20,8 @@ export interface Course {
2020

2121
/**相对于学期的上课时间、地点 */
2222
export interface ClassArrangement {
23-
/**单双周,或表示学期中第n周 */
24-
weekType: 'odd' | 'even' | 'every' | WeekOfSemester
25-
/**星期几 */
23+
/**上课周次 */
24+
weekType: WeekType
2625
dayOfWeek: DayOfWeek
2726
/**从第几节开始 =djj */
2827
startSection: number

src/models/shared.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ export type DayOfWeek = 1 | 2 | 3 | 4 | 5 | 6 | 7
44
export type WeekOfSemester =
55
| (1 | 2 | 3 | 4 | 5 | 6 | 7 | 8)
66
| (9 | 10 | 11 | 12 | 13 | 14 | 15 | 16)
7+
/**使用位域表示长学期中的多个周,第n周为2^n,最低位保留。如1、4周表示为0b10010 */
8+
export type MultipleWeeksOfSemester = number
9+
/**表示单双周/每周/学期中特定的几周。 */
10+
export type WeekType = 'odd' | 'even' | 'every' | MultipleWeeksOfSemester
711

812
/**学期,支持:春/夏/秋/冬/短/春夏/秋冬 */
913
export enum Term {

src/spiders/CourseSpider.ts

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
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+
}

src/utils/extendMap.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
type ElementType<A> = A extends (infer E)[] ? E : never
2+
3+
interface Map<K, V> {
4+
/**如果指定的键存在,向对应的值push一个元素;否则新建一个仅有该元素的数组,并插入Map。 */
5+
pushValue(key: K, element: ElementType<V>): void
6+
/**如果指定的键存在,则获取该值,并尝试调用onExists;否则,调用valueInit,插入新的值并返回该新值。 */
7+
ensure(key: K, valueInit: () => V, onExists?: (v: V) => void): V
8+
}
9+
10+
Object.defineProperty(Map.prototype, 'pushValue', {
11+
value(key, element) {
12+
if (this.has(key)) this.get(key)!.push(element)
13+
else this.set(key, [element])
14+
},
15+
} satisfies ThisType<Map<unknown, unknown[]>> & {
16+
value: Map<unknown, unknown[]>['pushValue']
17+
})
18+
Object.defineProperty(Map.prototype, 'ensure', {
19+
value(key, value, onExists) {
20+
if (this.has(key)) {
21+
const prevValue = this.get(key)!
22+
onExists?.(prevValue)
23+
return prevValue
24+
}
25+
const v = value()
26+
this.set(key, v)
27+
return v
28+
},
29+
} satisfies ThisType<Map<unknown, unknown>> & {
30+
value: Map<unknown, unknown>['ensure']
31+
})

src/utils/functions.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,8 @@ export function afterDone<T>(
1616
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any
1717
else f(result as any)
1818
}
19+
20+
/**新建一个函数,该函数始终返回调用ret时的参数 */
21+
export function constF<R>(arg: R): () => R {
22+
return () => arg
23+
}

src/utils/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
/**utils初始化文件,主要用于加载扩展原型方法 */
2+
3+
import './extendHeaders'
4+
import './extendMap'

0 commit comments

Comments
 (0)