diff --git a/src/index.ts b/src/index.ts index 58b8d6f..0da31cb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -30,6 +30,8 @@ import { INotificationDetail, Language, Notification, + QNR, + QNRType, Question, RemoteFile, SemesterInfo, @@ -40,6 +42,7 @@ import { CONTENT_TYPE_MAP_REVERSE, GRADE_LEVEL_MAP, JSONP_EXTRACTOR_NAME, + QNR_TYPE_MAP, decodeHTML, extractJSONPResult, formatFileSize, @@ -342,6 +345,8 @@ export class Learn2018Helper { return this.getDiscussionList(id, courseType) as Promise; case ContentType.QUESTION: return this.getAnsweredQuestionList(id, courseType) as Promise; + case ContentType.QNR: + return this.getQuestionnaireList(id) as Promise; default: return Promise.reject({ reason: FailReason.NOT_IMPLEMENTED, @@ -694,6 +699,46 @@ export class Learn2018Helper { ); } + /** + * Get all questionnaires (课程问卷/QNR) of the specified course. + */ + public async getQuestionnaireList(courseID: string): Promise { + return Promise.all([ + this.getQuestionnaireListAtUrl(courseID, URLS.LEARN_QNR_LIST_ONGOING), + this.getQuestionnaireListAtUrl(courseID, URLS.LEARN_QNR_LIST_ENDED), + ]).then((r) => r.flat()); + } + + async getQuestionnaireListAtUrl(courseID: string, url: string): Promise { + const json = await ( + await this.#myFetchWithToken(url, { method: 'POST', body: URLS.LEARN_PAGE_LIST_FORM_DATA(courseID) }) + ).json(); + if (json.result !== 'success') { + return Promise.reject({ + reason: FailReason.INVALID_RESPONSE, + extra: json, + } as ApiError); + } + const result = (json.object?.aaData ?? []) as any[]; + return result.map((e) => { + const type = QNR_TYPE_MAP.get(e.wjlx) ?? QNRType.SURVEY; + return { + id: e.wjid, + type, + title: decodeHTML(e.wjbt), + startTime: new Date(e.kssj), + endTime: new Date(e.jssj), + uploadTime: new Date(e.scsj), + uploaderId: e.scr, + uploaderName: e.scrxm, + submitTime: e.tjsj ? new Date(e.tjsj) : undefined, + isFavorite: e.sfsc === YES, + comment: e.bznr ?? undefined, + url: URLS.LEARN_QNR_DETAIL(e.wlkcid, e.wjid, type), + } satisfies QNR; + }); + } + /** * Add an item to favorites. (收藏) */ @@ -728,7 +773,7 @@ export class Learn2018Helper { const json = await ( await this.#myFetchWithToken(URLS.LEARN_FAVORITE_LIST(type), { method: 'POST', - body: URLS.LEARN_FAVORITE_OR_COMMENT_LIST_FORM_DATA(courseID), + body: URLS.LEARN_PAGE_LIST_FORM_DATA(courseID), }) ).json(); if (json.result !== 'success') { @@ -824,7 +869,7 @@ export class Learn2018Helper { const json = await ( await this.#myFetchWithToken(URLS.LEARN_COMMENT_LIST(type), { method: 'POST', - body: URLS.LEARN_FAVORITE_OR_COMMENT_LIST_FORM_DATA(courseID), + body: URLS.LEARN_PAGE_LIST_FORM_DATA(courseID), }) ).json(); if (json.result !== 'success') { diff --git a/src/types.ts b/src/types.ts index 61d207b..4616882 100644 --- a/src/types.ts +++ b/src/types.ts @@ -40,6 +40,7 @@ export enum ContentType { HOMEWORK = 'homework', DISCUSSION = 'discussion', QUESTION = 'question', + QNR = 'questionnaire', } interface IUserInfo { @@ -288,12 +289,36 @@ interface IQuestion extends IDiscussionBase { export type Question = IQuestion; +export enum QNRType { + VOTE = 'tp', + FORM = 'tb', + SURVEY = 'wj', +} + +interface IQNR { + id: string; + type: QNRType; + title: string; + startTime: Date; + endTime: Date; + uploadTime: Date; + uploaderId: string; + uploaderName: string; + submitTime?: Date; + isFavorite: boolean; + comment?: string; + url: string; +} + +export type QNR = IQNR; + export type ContentTypeMap = { [ContentType.NOTIFICATION]: Notification; [ContentType.FILE]: File; [ContentType.HOMEWORK]: Homework; [ContentType.DISCUSSION]: Discussion; [ContentType.QUESTION]: Question; + [ContentType.QNR]: QNR; }; interface ICourseContent { diff --git a/src/urls.ts b/src/urls.ts index c74955c..9a9f0a2 100644 --- a/src/urls.ts +++ b/src/urls.ts @@ -1,5 +1,5 @@ import { FormData } from 'node-fetch-native'; -import { ContentType, CourseType, IHomeworkSubmitAttachment, Language } from './types'; +import { ContentType, CourseType, IHomeworkSubmitAttachment, Language, QNRType } from './types'; import { CONTENT_TYPE_MAP, getMkFromType } from './utils'; export const LEARN_PREFIX = 'https://learn.tsinghua.edu.cn'; @@ -180,6 +180,11 @@ export const LEARN_QUESTION_DETAIL = (courseID: string, questionID: string, cour ? `${LEARN_PREFIX}/f/wlxt/bbs/bbs_kcdy/student/viewDyById?wlkcid=${courseID}&id=${questionID}` : `${LEARN_PREFIX}/f/wlxt/bbs/bbs_kcdy/teacher/beforeEditDy?wlkcid=${courseID}&id=${questionID}`; +export const LEARN_QNR_LIST_ONGOING = `${LEARN_PREFIX}/b/wlxt/kcwj/wlkc_wjb/student/pageListWks`; +export const LEARN_QNR_LIST_ENDED = `${LEARN_PREFIX}/b/wlxt/kcwj/wlkc_wjb/student/pageListYjs`; +export const LEARN_QNR_DETAIL = (courseID: string, qnrID: string, type: QNRType) => + `${LEARN_PREFIX}/f/wlxt/kcwj/wlkc_wjb/student/beforeAdd?wlkcid=${courseID}&wjid=${qnrID}&wjlx=${type}&jswj=no`; + export const WebsiteShowLanguage = { [Language.ZH]: 'zh_CN', [Language.EN]: 'en_US', @@ -219,7 +224,7 @@ export const LEARN_COMMENT_SET_FORM_DATA = (type: ContentType, id: string, conte export const LEARN_COMMENT_LIST = (type?: ContentType) => `${LEARN_PREFIX}/b/wlxt/xt/wlkc_xsbjb/student/pageList?ywlx=${type ? CONTENT_TYPE_MAP.get(type) : 'ALL'}`; -export const LEARN_FAVORITE_OR_COMMENT_LIST_FORM_DATA = (courseID?: string) => { +export const LEARN_PAGE_LIST_FORM_DATA = (courseID?: string) => { const form = new FormData(); form.append('aoData', JSON.stringify(courseID ? [{ name: 'wlkcid', value: courseID }] : [])); return form; diff --git a/src/utils.ts b/src/utils.ts index a161ed1..488186c 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,6 +1,6 @@ import { decodeHTML as _decodeHTML } from 'entities'; -import { ContentType, FailReason, HomeworkGradeLevel, SemesterType } from './types'; +import { ContentType, FailReason, HomeworkGradeLevel, QNRType, SemesterType } from './types'; export function parseSemesterType(n: number): SemesterType { if (n === 1) { @@ -20,6 +20,7 @@ const CONTENT_TYPE_MK_MAP = { [ContentType.HOMEWORK]: 'kczy', [ContentType.DISCUSSION]: '', [ContentType.QUESTION]: '', + [ContentType.QNR]: '', }; export function getMkFromType(type: ContentType): string { @@ -99,7 +100,8 @@ export const CONTENT_TYPE_MAP = new Map([ [ContentType.HOMEWORK, 'KCZY'], [ContentType.DISCUSSION, 'KCTL'], [ContentType.QUESTION, 'KCDY'], - // omitted: 问卷(KCWJ) & 课表(KCKB) as they are not supported now + [ContentType.QNR, 'KCWJ'], + // omitted: 课表(KCKB) ]); export const CONTENT_TYPE_MAP_REVERSE = new Map([ [CONTENT_TYPE_MAP.get(ContentType.NOTIFICATION)!, ContentType.NOTIFICATION], @@ -107,4 +109,11 @@ export const CONTENT_TYPE_MAP_REVERSE = new Map([ [CONTENT_TYPE_MAP.get(ContentType.HOMEWORK)!, ContentType.HOMEWORK], [CONTENT_TYPE_MAP.get(ContentType.DISCUSSION)!, ContentType.DISCUSSION], [CONTENT_TYPE_MAP.get(ContentType.QUESTION)!, ContentType.QUESTION], + [CONTENT_TYPE_MAP.get(ContentType.QNR)!, ContentType.QNR], +]); + +export const QNR_TYPE_MAP = new Map([ + ['投票', QNRType.VOTE], + ['填表', QNRType.FORM], + ['问卷', QNRType.SURVEY], ]);