diff --git a/src/index.ts b/src/index.ts index cf5979f..ab85981 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,6 +14,7 @@ import { CredentialProvider, Discussion, FailReason, + FavoriteItem, Fetch, File, FileCategory, @@ -36,6 +37,7 @@ import { } from './types'; import * as URLS from './urls'; import { + FAVORITE_TYPE_MAP_REVERSE, GRADE_LEVEL_MAP, JSONP_EXTRACTOR_NAME, decodeHTML, @@ -717,6 +719,108 @@ export class Learn2018Helper { return questions; } + /** + * Add an item to favorites. (收藏) + */ + public async addToFavorites(type: ContentType, id: string): Promise { + const json = await (await this.#myFetchWithToken(URLS.LEARN_FAVORITE_ADD(type, id))).json(); + if (json.result !== 'success') { + return Promise.reject({ + reason: FailReason.INVALID_RESPONSE, + extra: json, + } as ApiError); + } + } + + /** + * Remove an item from favorites. (取消收藏) + */ + public async removeFromFavorites(id: string): Promise { + const json = await (await this.#myFetchWithToken(URLS.LEARN_FAVORITE_REMOVE(id))).json(); + if (json.result !== 'success') { + return Promise.reject({ + reason: FailReason.INVALID_RESPONSE, + extra: json, + } as ApiError); + } + } + + /** + * Get favorites. (我的收藏) + * If `courseID` or `type` is specified, only return favorites of that course or type. + */ + public async getFavorites(courseID?: string, type?: ContentType): Promise { + const json = await ( + await this.#myFetchWithToken(URLS.LEARN_FAVORITE_LIST(type), { + method: 'POST', + body: URLS.LEARN_FAVORITE_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): FavoriteItem | undefined => { + const type = FAVORITE_TYPE_MAP_REVERSE.get(e.ywlx); + if (!type) return; // ignore unknown type + return { + id: e.ywid, + type, + title: decodeHTML(e.ywbt), + time: type === ContentType.DISCUSSION || type === ContentType.QUESTION ? new Date(e.tlsj) : new Date(e.ywsj), + state: e.ywzt, + extra: e.ywbz ?? undefined, + semesterId: e.xnxq, + courseId: e.wlkcid, + pinned: e.sfzd === '是', + pinnedTime: e.zdsj === null ? undefined : new Date(e.zdsj), // Note: this field is originally unix timestamp instead of string + addedTime: new Date(e.scsj), + itemId: e.id, + } satisfies FavoriteItem; + }) + .filter((x) => !!x); + } + + /** + * Pin a favorite item. (置顶) + */ + public async pinFavoriteItem(id: string): Promise { + const json = await ( + await this.#myFetchWithToken(URLS.LEARN_FAVORITE_PIN, { + method: 'POST', + body: URLS.LEARN_FAVORITE_PIN_UNPIN_FORM_DATA(id), + }) + ).json(); + if (json.result !== 'success') { + return Promise.reject({ + reason: FailReason.INVALID_RESPONSE, + extra: json, + } as ApiError); + } + } + + /** + * Unpin a favorite item. (取消置顶) + */ + public async unpinFavoriteItem(id: string): Promise { + const json = await ( + await this.#myFetchWithToken(URLS.LEARN_FAVORITE_UNPIN, { + method: 'POST', + body: URLS.LEARN_FAVORITE_PIN_UNPIN_FORM_DATA(id), + }) + ).json(); + if (json.result !== 'success') { + return Promise.reject({ + reason: FailReason.INVALID_RESPONSE, + extra: json, + } as ApiError); + } + } + private async getHomeworkListAtUrl(url: string, status: IHomeworkStatus): Promise { const json = await (await this.#myFetchWithToken(url)).json(); if (json.result !== 'success') { diff --git a/src/types.ts b/src/types.ts index 107c90e..5d8a8ad 100644 --- a/src/types.ts +++ b/src/types.ts @@ -295,6 +295,26 @@ interface ICourseContent { export type CourseContent = ICourseContent; +interface IFavoriteItem { + id: string; + type: ContentType; + title: string; + time: Date; + state: string; + /** extra message. For homework, this will be deadline (plus score if graded). It's too flexible and hard to parse so we leave it as is. */ + extra?: string; + semesterId: string; + courseId: string; + pinned: boolean; + pinnedTime?: Date; + // comment?: string; + addedTime: Date; + /** for reference */ + itemId: string; +} + +export type FavoriteItem = IFavoriteItem; + export interface CalendarEvent { location: string; status: string; diff --git a/src/urls.ts b/src/urls.ts index ee5b41d..6cb7d2f 100644 --- a/src/urls.ts +++ b/src/urls.ts @@ -1,6 +1,6 @@ import { FormData } from 'node-fetch-native'; import { ContentType, CourseType, IHomeworkSubmitAttachment, Language } from './types'; -import { getMkFromType } from './utils'; +import { FAVORITE_TYPE_MAP, getMkFromType } from './utils'; export const LEARN_PREFIX = 'https://learn.tsinghua.edu.cn'; export const REGISTRAR_PREFIX = 'https://zhjw.cic.tsinghua.edu.cn'; @@ -188,6 +188,30 @@ export const WebsiteShowLanguage = { export const LEARN_WEBSITE_LANGUAGE = (lang: Language) => `https://learn.tsinghua.edu.cn/f/wlxt/common/language?websiteShowLanguage=${WebsiteShowLanguage[lang]}`; +export const LEARN_FAVORITE_ADD = (type: ContentType, id: string) => + `${LEARN_PREFIX}/b/xt/wlkc_xsscb/student/add?ywid=${id}&ywlx=${FAVORITE_TYPE_MAP.get(type)}`; + +export const LEARN_FAVORITE_REMOVE = (id: string) => `${LEARN_PREFIX}/b/xt/wlkc_xsscb/student/delete?ywid=${id}`; + +export const LEARN_FAVORITE_LIST = (type?: ContentType) => + `${LEARN_PREFIX}/b/xt/wlkc_xsscb/student/pageList?ywlx=${type ? FAVORITE_TYPE_MAP.get(type) : 'ALL'}`; + +export const LEARN_FAVORITE_LIST_FORM_DATA = (courseID?: string) => { + const form = new FormData(); + form.append('aoData', JSON.stringify(courseID ? [{ name: 'wlkcid', value: courseID }] : [])); + return form; +}; + +export const LEARN_FAVORITE_PIN = `${LEARN_PREFIX}/b/xt/wlkc_xsscb/student/addZd`; + +export const LEARN_FAVORITE_UNPIN = `${LEARN_PREFIX}/b/xt/wlkc_xsscb/student/delZd`; + +export const LEARN_FAVORITE_PIN_UNPIN_FORM_DATA = (id: string) => { + const form = new FormData(); + form.append('ywid', id); + return form; +}; + export const REGISTRAR_TICKET_FORM_DATA = () => { const form = new FormData(); form.append('appId', 'ALL_ZHJW'); diff --git a/src/utils.ts b/src/utils.ts index 39f9c9a..36243ce 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -14,16 +14,16 @@ export function parseSemesterType(n: number): SemesterType { } } -const CONTENT_TYPE_MK_MAP = new Map([ - [ContentType.NOTIFICATION, 'kcgg'], - [ContentType.FILE, 'kcwj'], - [ContentType.HOMEWORK, 'kczy'], - [ContentType.DISCUSSION, ''], - [ContentType.QUESTION, ''], -]); +const CONTENT_TYPE_MK_MAP = { + [ContentType.NOTIFICATION]: 'kcgg', + [ContentType.FILE]: 'kcwj', + [ContentType.HOMEWORK]: 'kczy', + [ContentType.DISCUSSION]: '', + [ContentType.QUESTION]: '', +}; export function getMkFromType(type: ContentType): string { - return 'mk_' + (CONTENT_TYPE_MK_MAP.get(type) ?? 'UNKNOWN'); + return 'mk_' + (CONTENT_TYPE_MK_MAP[type] ?? 'UNKNOWN'); } export function decodeHTML(html: string): string { @@ -92,3 +92,19 @@ export function formatFileSize(size: number): string { if (size < 1024 * 1024 * 1024) return (size / 1024 / 1024).toFixed(2) + 'M'; return (size / 1024 / 1024 / 1024).toFixed(2) + 'G'; } + +export const FAVORITE_TYPE_MAP = new Map([ + [ContentType.NOTIFICATION, 'KCGG'], + [ContentType.FILE, 'KCKJ'], + [ContentType.HOMEWORK, 'KCZY'], + [ContentType.DISCUSSION, 'KCTL'], + [ContentType.QUESTION, 'KCDY'], + // omitted: 问卷(KCWJ) & 课表(KCKB) as they are not supported now +]); +export const FAVORITE_TYPE_MAP_REVERSE = new Map([ + [FAVORITE_TYPE_MAP.get(ContentType.NOTIFICATION)!, ContentType.NOTIFICATION], + [FAVORITE_TYPE_MAP.get(ContentType.FILE)!, ContentType.FILE], + [FAVORITE_TYPE_MAP.get(ContentType.HOMEWORK)!, ContentType.HOMEWORK], + [FAVORITE_TYPE_MAP.get(ContentType.DISCUSSION)!, ContentType.DISCUSSION], + [FAVORITE_TYPE_MAP.get(ContentType.QUESTION)!, ContentType.QUESTION], +]);