import * as cheerio from 'cheerio' ; import { Base64 } from 'js-base64' ; import fetch from 'cross-fetch' ; import * as URL from './urls' ; import { CredentialProvider , Fetch , HelperConfig , Content , ContentType , CourseContent , CourseInfo , Discussion , FailReason , File , Homework , IDiscussionBase , IHomeworkDetail , IHomeworkStatus , INotification , INotificationDetail , Notification , Question , SemesterInfo , CourseType , CalendarEvent , ApiError , RemoteFile , } from './types' ; import { decodeHTML , mapGradeToLevel , parseSemesterType , trimAndDefine , JSONP_EXTRACTOR_NAME , extractJSONPResult , } from './utils' ; import IsomorphicFetch from 'real-isomorphic-fetch' ; import tough from 'tough-cookie-no-native' ; const CHEERIO_CONFIG = { _useHtmlParser2 : true , decodeEntities : false , } ; const $ = ( html ) : cheerio . Root => { return cheerio . load ( html , CHEERIO_CONFIG ) ; } ; const noLogin = ( res ) => res . url . includes ( 'login_timeout' ) || res . status == 403 ; /** add CSRF token to any request URL as parameters */ export const addCSRFTokenToUrl = ( url , token ) : string => { if ( url . includes ( '?' ) ) { url += `&_csrf=${ token }` ; } else { url += `?_csrf=${ token }` ; } return url ; } ; /** the main helper class */ export class Learn2018Helper { readonly #provider ? : CredentialProvider ; readonly #rawFetch : Fetch ; readonly #myFetch : Fetch ; readonly #myFetchWithToken : Fetch = async ( ... args ) => { if ( this . #csrfToken == '' ) { await this . login ( ) ; } const [ url , ... remaining ] = args ; return this . #myFetch ( addCSRFTokenToUrl ( url as string , this . #csrfToken ) , ... remaining ) ; } ; #csrfToken = '' ; readonly #withReAuth = ( rawFetch ) : Fetch => { const login = this . login . bind ( this ) ; return async function wrappedFetch ( ... args ) { const retryAfterLogin = async ( ) => { await login ( ) ; return await rawFetch ( ... args ) . then ( ( res ) => { if ( noLogin ( res ) ) { return Promise . reject ( { reason : FailReason . NOT_LOGGED_IN , } as ApiError ) ; } else if ( res . status != 200 ) { return Promise . reject ( { reason : FailReason . UNEXPECTED_STATUS , extra : { code : res . status , text : res . statusText , } , } as ApiError ) ; } else { return res ; } } ) ; } ; return await rawFetch ( ... args ) . then ( ( res ) => ( noLogin ( res ) ? retryAfterLogin ( ) : res ) ) ; } ; } ; public readonly cookieJar : any ; public previewFirstPage : boolean ; /** you can provide a CookieJar and / or CredentialProvider in the configuration */ constructor ( config ? ) { this . previewFirstPage = config ?. generatePreviewUrlForFirstPage ?? true ; this . cookieJar = config ?. cookieJar ?? new tough . CookieJar ( ) ; this . #provider = config ?. provider ; this . #rawFetch = new IsomorphicFetch ( fetch , this . cookieJar ) ; this . #myFetch = this . #provider ? this . #withReAuth ( this . #rawFetch ) : async ( ... args ) => { const result = await this . #rawFetch ( ... args ) ; if ( noLogin ( result ) ) return Promise . reject ( { reason : FailReason . NOT_LOGGED_IN , } as ApiError ) ; return result ; } ; } /** fetch CSRF token from helper (invalid after login / re-login), might be '' if not logged in */ public getCSRFToken ( ) { return this . #csrfToken ; } /** login is necessary if you do not provide a `CredentialProvider` */ public async login ( username ? , password ? ) { if ( ! username || ! password ) { if ( ! this . #provider ) return Promise . reject ( { reason : FailReason . NO_CREDENTIAL , } as ApiError ) ; const credential = await this . #provider ( ) ; username = credential . username ; password = credential . password ; } const ticketResponse = await this . #rawFetch ( URL . ID_LOGIN ( ) , { body : URL . ID_LOGIN_FORM_DATA ( username , password ) , method : 'POST' , } ) ; if ( ! ticketResponse . ok ) { return Promise . reject ( { reason : FailReason . ERROR_FETCH_FROM_ID , } as ApiError ) ; } const ticketResult = await ticketResponse . text ( ) ; const body = $ ( ticketResult ) ; const targetURL = body ( 'a' ) . attr ( 'href' ) as string ; const ticket = targetURL . split ( '=' ) . slice ( - 1 ) [ 0 ] ; if ( ticket === 'BAD_CREDENTIALS' ) { return Promise . reject ( { reason : FailReason . BAD_CREDENTIAL , } as ApiError ) ; } const loginResponse = await this . #rawFetch ( URL . LEARN_AUTH_ROAM ( ticket ) ) ; if ( loginResponse . ok !== true ) { return Promise . reject ( { reason : FailReason . ERROR_ROAMING , } as ApiError ) ; } const courseListPageSource = await ( await this . #rawFetch ( URL . LEARN_STUDENT_COURSE_LIST_PAGE ( ) ) ) . text ( ) ; const tokenRegex = /^.*&_csrf=(\S*)".*$/gm ; const matches = [ ... courseListPageSource . matchAll ( tokenRegex ) ] ; if ( matches . length == 0 ) { return Promise . reject ( { reason : FailReason . INVALID_RESPONSE , extra : 'cannot fetch CSRF token from source' , } as ApiError ) ; } this . #csrfToken = matches [ 0 ] [ 1 ] ; } /**  logout (to make everyone happy) */ public async logout ( ) { await this . #rawFetch ( URL . LEARN_LOGOUT ( ) , { method : 'POST' } ) ; } param startDate param endDate public async getCalendar ( startDate , endDate , graduate = false ) { const ticketResponse = await this . #myFetchWithToken ( URL . REGISTRAR_TICKET ( ) , { method : 'POST' , body : URL . REGISTRAR_TICKET_FORM_DATA ( ) , } ) ; let ticket = ( await ticketResponse . text ( ) ) as string ; ticket = ticket . substring ( 1 , ticket . length - 1 ) ; await this . #myFetch ( URL . REGISTRAR_AUTH ( ticket ) ) ; const response = await this . #myFetch ( URL . REGISTRAR_CALENDAR ( startDate , endDate , graduate , JSONP_EXTRACTOR_NAME ) ) ; if ( ! response . ok ) { return Promise . reject ( { reason : FailReason . INVALID_RESPONSE , } as ApiError ) ; } const result = extractJSONPResult ( await response . text ( ) ) as any [ ] ; return result . map < CalendarEvent > ( ( i ) => ( { location : i . dd , status : i . fl , startTime : i . kssj , endTime : i . jssj , date : i . nq , courseName : i . nr , } ) ) ; } public async getSemesterIdList ( ) { const json = await ( await this . #myFetchWithToken ( URL . LEARN_SEMESTER_LIST ( ) ) ) . json ( ) ; if ( ! Array . isArray ( json ) ) { return Promise . reject ( { reason : FailReason . INVALID_RESPONSE , extra : json , } as ApiError ) ; } const semesters = json as string [ ] ; return semesters . filter ( ( s ) => s != null ) ; } public async getCurrentSemester ( ) { const json = await ( await this . #myFetchWithToken ( URL . LEARN_CURRENT_SEMESTER ( ) ) ) . json ( ) ; if ( json . message !== 'success' ) { return Promise . reject ( { reason : FailReason . INVALID_RESPONSE , extra : json , } as ApiError ) ; } const result = json . result ; return { id : result . id , startDate : new Date ( result . kssj ) , endDate : new Date ( result . jssj ) , startYear : Number ( result . xnxq . slice ( 0 , 4 ) ) , endYear : Number ( result . xnxq . slice ( 5 , 9 ) ) , type : parseSemesterType ( Number ( result . xnxq . slice ( 10 , 11 ) ) ) , } ; } /** get all courses in the specified semester */ public async getCourseList ( semesterID , courseType = CourseType . STUDENT ) { const json = await ( await this . #myFetchWithToken ( URL . LEARN_COURSE_LIST ( semesterID , courseType ) ) ) . json ( ) ; if ( json . message !== 'success' || ! Array . isArray ( json . resultList ) ) { return Promise . reject ( { reason : FailReason . INVALID_RESPONSE , extra : json , } as ApiError ) ; } const result = ( json . resultList ?? [ ] ) as any [ ] ; const courses = [ ] ; await Promise . all ( result . map ( async ( c ) => { courses . push ( { id : c . wlkcid , name : c . kcm , englishName : c . ywkcm , timeAndLocation : await ( await this . #myFetchWithToken ( URL . LEARN_COURSE_TIME_LOCATION ( c . wlkcid ) ) ) . json ( ) , url : URL . LEARN_COURSE_URL ( c . wlkcid , courseType ) , teacherName : c . jsm ?? '' , teacherNumber : c . jsh , courseNumber : c . kch , courseIndex : Number ( c . kxh ) , courseType , } ) ; } ) ) ; return courses ; } /**
     * Get certain type of content of all specified courses.
     * It actually wraps around other `getXXX` functions. You can ignore the failure caused by certain courses.
     */ public async getAllContents ( courseIDs , type , courseType = CourseType . STUDENT , allowFailure = false ) { let fetchFunc ; switch ( type ) { case ContentType . NOTIFICATION : fetchFunc = this . getNotificationList ; break ; case ContentType . FILE : fetchFunc = this . getFileList ; break ; case ContentType . HOMEWORK : fetchFunc = this . getHomeworkList ; break ; case ContentType . DISCUSSION : fetchFunc = this . getDiscussionList ; break ; case ContentType . QUESTION : fetchFunc = this . getAnsweredQuestionList ; break ; } const contents = { } ; const results = await Promise . allSettled ( courseIDs . map ( async ( id ) => { contents [ id ] = await fetchFunc . bind ( this ) ( id , courseType ) ; } ) ) ; if ( ! allowFailure ) { for ( const r of results ) { if ( r . status == 'rejected' ) { return Promise . reject ( { reason : FailReason . INVALID_RESPONSE , extra : { reason : r . reason , } , } as ApiError ) ; } } } return contents ; } /** Get all notifications （课程公告） of the specified course. */ public async getNotificationList ( courseID , courseType = CourseType . STUDENT ) { const json = await ( await this . #myFetchWithToken ( URL . LEARN_NOTIFICATION_LIST ( courseID , courseType ) ) ) . json ( ) ; if ( json . result !== 'success' ) { return Promise . reject ( { reason : FailReason . INVALID_RESPONSE , extra : json , } as ApiError ) ; } const result = ( json . object ?. aaData ?? json . object ?. resultsList ?? [ ] ) as any [ ] ; const notifications = [ ] ; await Promise . all ( result . map ( async ( n ) => { const notification = { id : n . ggid , content : decodeHTML ( Base64 . decode ( n . ggnr ?? '' ) ) , title : decodeHTML ( n . bt ) , url : URL . LEARN_NOTIFICATION_DETAIL ( courseID , n . ggid , courseType ) , publisher : n . fbrxm , hasRead : n . sfyd === '是' , markedImportant : Number ( n . sfqd ) === 1 , publishTime : new Date ( n . fbsj && typeof n . fbsj === 'string' ? n . fbsj : n . fbsjStr ) , } ; let detail = { } ; const attachmentName = courseType === CourseType . STUDENT ? n . fjmc : n . fjbt ; if ( attachmentName ) { detail = await this . parseNotificationDetail ( courseID , notification . id , courseType , attachmentName ) ; } notifications . push ( { ... notification , ... detail } ) ; } ) ) ; return notifications ; } /** Get all files （课程文件） of the specified course. */ public async getFileList ( courseID , courseType = CourseType . STUDENT ) { const json = await ( await this . #myFetchWithToken ( URL . LEARN_FILE_LIST ( courseID , courseType ) ) ) . json ( ) ; if ( json . result !== 'success' ) { return Promise . reject ( { reason : FailReason . INVALID_RESPONSE , extra : json , } as ApiError ) ; } let result = [ ] ; if ( Array . isArray ( json . object ?. resultsList ) ) { result = json . object . resultsList ; } else if ( Array . isArray ( json . object ) ) { result = json . object ; } const files = [ ] ; await Promise . all ( result . map ( async ( f ) => { const title = decodeHTML ( f . bt ) ; const downloadUrl = URL . LEARN_FILE_DOWNLOAD ( courseType === CourseType . STUDENT ? f . wjid : f . id , courseType , courseID ) ; const previewUrl = URL . LEARN_FILE_PREVIEW ( ContentType . FILE , f . wjid , courseType , this . previewFirstPage ) ; files . push ( { id : f . wjid , title : decodeHTML ( f . bt ) , description : decodeHTML ( f . ms ) , rawSize : f . wjdx , size : f . fileSize , uploadTime : new Date ( f . scsj ) , downloadUrl , previewUrl , isNew : f . isNew , markedImportant : f . sfqd === 1 , visitCount : f . llcs ?? 0 , downloadCount : f . xzcs ?? 0 , fileType : f . wjlx , remoteFile : { id : f . wjid , name : title , downloadUrl , previewUrl , size : f . fileSize , } , } ) ; } ) ) ; return files ; } /** Get all homeworks （课程作业） of the specified course (support student version only). */ public async getHomeworkList ( courseID , courseType = CourseType . STUDENT ) { if ( courseType === CourseType . TEACHER ) { return Promise . reject ( { reason : FailReason . NOT_IMPLEMENTED , extra : 'currently getting homework list of TA courses is not supported' , } as ApiError ) ; } const allHomework = [ ] ; await Promise . all ( URL . LEARN_HOMEWORK_LIST_SOURCE ( courseID ) . map ( async ( s ) => { const homeworks = await this . getHomeworkListAtUrl ( s . url , s . status ) ; allHomework . push ( ... homeworks ) ; } ) ) ; return allHomework ; } /** Get all discussions （课程讨论） of the specified course. */ public async getDiscussionList ( courseID , courseType = CourseType . STUDENT ) { const json = await ( await this . #myFetchWithToken ( URL . LEARN_DISCUSSION_LIST ( courseID , courseType ) ) ) . json ( ) ; if ( json . result !== 'success' ) { return Promise . reject ( { reason : FailReason . INVALID_RESPONSE , extra : json , } as ApiError ) ; } const result = ( json . object ?. resultsList ?? [ ] ) as any [ ] ; const discussions = [ ] ; await Promise . all ( result . map ( async ( d ) => { discussions . push ( { ... this . parseDiscussionBase ( d ) , boardId : d . bqid , url : URL . LEARN_DISCUSSION_DETAIL ( d . wlkcid , d . bqid , d . id , courseType ) , } ) ; } ) ) ; return discussions ; } /**
     * Get all notifications （课程答疑） of the specified course.
     * The student version supports only answered questions, while the teacher version supports all questions.
     */ public async getAnsweredQuestionList ( courseID , courseType = CourseType . STUDENT ) { const json = await ( await this . #myFetchWithToken ( URL . LEARN_QUESTION_LIST_ANSWERED ( courseID , courseType ) ) ) . json ( ) ; if ( json . result !== 'success' ) { return Promise . reject ( { reason : FailReason . INVALID_RESPONSE , extra : json , } as ApiError ) ; } const result = ( json . object ?. resultsList ?? [ ] ) as any [ ] ; const questions = [ ] ; await Promise . all ( result . map ( async ( q ) => { questions . push ( { ... this . parseDiscussionBase ( q ) , question : Base64 . decode ( q . wtnr ) , url : URL . LEARN_QUESTION_DETAIL ( q . wlkcid , q . id , courseType ) , } ) ; } ) ) ; return questions ; } private async getHomeworkListAtUrl ( url , status ) { const json = await ( await this . #myFetchWithToken ( url ) ) . json ( ) ; if ( json . result !== 'success' ) { return Promise . reject ( { reason : FailReason . INVALID_RESPONSE , extra : json , } as ApiError ) ; } const result = ( json . object ?. aaData ?? [ ] ) as any [ ] ; const homeworks = [ ] ; await Promise . all ( result . map ( async ( h ) => { homeworks . push ( { id : h . zyid , studentHomeworkId : h . xszyid , title : decodeHTML ( h . bt ) , url : URL . LEARN_HOMEWORK_DETAIL ( h . wlkcid , h . zyid , h . xszyid ) , deadline : new Date ( h . jzsj ) , submitUrl : URL . LEARN_HOMEWORK_SUBMIT ( h . wlkcid , h . xszyid ) , submitTime : h . scsj === null ? undefined : new Date ( h . scsj ) , grade : h . cj === null ? undefined : h . cj , gradeLevel : mapGradeToLevel ( h . cj ) , graderName : trimAndDefine ( h . jsm ) , gradeContent : trimAndDefine ( h . pynr ) , gradeTime : h . pysj === null ? undefined : new Date ( h . pysj ) , ... status , ... ( await this . parseHomeworkDetail ( h . wlkcid , h . zyid , h . xszyid ) ) , } ) ; } ) ) ; return homeworks ; } private async parseNotificationDetail ( courseID , id , courseType , attachmentName ) { const response = await this . #myFetchWithToken ( URL . LEARN_NOTIFICATION_DETAIL ( courseID , id , courseType ) ) ; const result = $ ( await response . text ( ) ) ; let path = '' ; if ( courseType === CourseType . STUDENT ) { path = result ( '.ml-10' ) . attr ( 'href' ) ! ; } else { path = result ( '#wjid' ) . attr ( 'href' ) ! ; } const size = trimAndDefine ( result ( 'div#attachment > div.fl > span[class^="color"]' ) . first ( ) . text ( ) ) ! ; const params = new URLSearchParams ( path . split ( '?' ) . slice ( - 1 ) [ 0 ] ) ; const attachmentId = params . get ( 'wjid' ) ! ; if ( ! path . startsWith ( URL . LEARN_PREFIX ) ) { path = URL . LEARN_PREFIX + path ; } return { attachment : { name : attachmentName , id : attachmentId , downloadUrl : path , previewUrl : URL . LEARN_FILE_PREVIEW ( ContentType . NOTIFICATION , attachmentId , courseType , this . previewFirstPage ) , size , } , } ; } private async parseHomeworkDetail ( courseID , id , studentHomeworkID ) { const response = await this . #myFetchWithToken ( URL . LEARN_HOMEWORK_DETAIL ( courseID , id , studentHomeworkID ) ) ; const result = $ ( await response . text ( ) ) ; const fileDivs = result ( 'div.list.fujian.clearfix' ) ; return { description : trimAndDefine ( result ( 'div.list.calendar.clearfix > div.fl.right > div.c55' ) . slice ( 0 , 1 ) . html ( ) ) , answerContent : trimAndDefine ( result ( 'div.list.calendar.clearfix > div.fl.right > div.c55' ) . slice ( 1 , 2 ) . html ( ) ) , submittedContent : trimAndDefine ( $ ( result ( 'div.boxbox' ) . slice ( 1 , 2 ) . toArray ( ) ) ( 'div.right' ) . slice ( 2 , 3 ) . html ( ) ) , attachment : this . parseHomeworkFile ( fileDivs [ 0 ] ) , answerAttachment : this . parseHomeworkFile ( fileDivs [ 1 ] ) , submittedAttachment : this . parseHomeworkFile ( fileDivs [ 2 ] ) , gradeAttachment : this . parseHomeworkFile ( fileDivs [ 3 ] ) , } ; } private parseHomeworkFile ( fileDiv ) { const fileNode = ( $ ( fileDiv ) ( '.ftitle' ) . children ( 'a' ) [ 0 ] ?? $ ( fileDiv ) ( '.fl' ) . children ( 'a' ) [ 0 ] ) as cheerio . TagElement ; if ( fileNode !== undefined ) { const size = trimAndDefine ( $ ( fileDiv ) ( '.fl > span[class^="color"]' ) . first ( ) . text ( ) ) ! ; const params = new URLSearchParams ( fileNode . attribs . href . split ( '?' ) . slice ( - 1 ) [ 0 ] ) ; const attachmentId = params . get ( 'fileId' ) ! ; let downloadUrl = URL . LEARN_PREFIX + fileNode . attribs . href ; if ( params . has ( 'downloadUrl' ) ) { downloadUrl = URL . LEARN_PREFIX + params . get ( 'downloadUrl' ) ! ; } return { id : attachmentId , name : fileNode . children [ 0 ] . data ! , downloadUrl , previewUrl : URL . LEARN_FILE_PREVIEW ( ContentType . HOMEWORK , attachmentId , CourseType . STUDENT , this . previewFirstPage ) , size , } ; } else { return undefined ; } } private parseDiscussionBase ( d ) { return { id : d . id , title : decodeHTML ( d . bt ) , publisherName : d . fbrxm , publishTime : new Date ( d . fbsj ) , lastReplyTime : new Date ( d . zhhfsj ) , lastReplierName : d . zhhfrxm , visitCount : d . djs ?? 0 , replyCount : d . hfcs , } ; } }