bkrapper 0.1.0

school portal scraper
Documentation
use std::collections::HashMap;

use serde::{de::Visitor, Deserialize};

pub struct Client {
    client: reqwest::Client,
    sesskey: String,
    stinfo_token: String,
}

trait HtmlExt {
    fn get_named_input(&self, name: &str) -> Option<&str>;
}

impl HtmlExt for scraper::Html {
    fn get_named_input(&self, name: &str) -> Option<&str> {
        let selector = scraper::Selector::parse(&format!(r#"[name="{name}"]"#)).ok()?;
        let mut element_ref =
            self.select(&selector)
                .map(|e| e.value())
                .filter_map(|e| match e.name() {
                    "input" => e.attr("value"),
                    "meta" => e.attr("content"),
                    _ => panic!("unknow named input"),
                });
        element_ref.next()
    }
}

impl Client {
    pub fn with_credential(username: &str, password: &str) -> Self {
        let client = reqwest::ClientBuilder::new()
            .cookie_store(true)
            .build()
            .expect("cannot build reqwest client");

        const LOGIN_URL: &str = "https://sso.hcmut.edu.vn/cas/login?service=http%3A%2F%2Fe-learning.hcmut.edu.vn%2Flogin%2Findex.php%3FauthCAS%3DCAS";
        let res = client.get(LOGIN_URL).send().unwrap();
        let html = scraper::Html::parse_document(&res.text().unwrap());
        let lt = html.get_named_input("lt").expect("lt not found");
        let exec_id = html
            .get_named_input("execution")
            .expect("execution not found");
        let event_id = html
            .get_named_input("_eventId")
            .expect("event id not found");

        client
            .post(LOGIN_URL)
            .form(&[
                ("lt", lt),
                ("username", username),
                ("password", password),
                ("execution", exec_id),
                ("_eventId", event_id),
                ("submit", "Login"),
            ])
            .send()
            .expect("login unsuccessfully");

        let res = client
            .get("http://e-learning.hcmut.edu.vn/my/")
            .send()
            .unwrap();

        let html = scraper::Html::parse_document(&res.text().unwrap());
        let sesskey = html
            .get_named_input("sesskey")
            .expect("sesskey not found")
            .to_owned();

        let res = client
            .get("https://mybk.hcmut.edu.vn/stinfo/")
            .send()
            .unwrap();

        let html = scraper::Html::parse_document(&res.text().unwrap());
        let stinfo_token = html
            .get_named_input("_token")
            .expect("sesskey not found")
            .to_owned();

        Self {
            client,
            sesskey,
            stinfo_token,
        }
    }

    pub fn get_courses(&self) -> Vec<Course> {
        const COURSES_URL: &str = "http://e-learning.hcmut.edu.vn/lib/ajax/service.php";
        const BODY: &str = r#"[
            {
                "args": {
                    "classification": "allincludinghidden",
                    "customfieldname": "",
                    "customfieldvalue": "",
                    "limit": 0,
                    "offset": 0,
                    "sort": "id desc"
                },
                "index": 0,
                "methodname": "core_course_get_enrolled_courses_by_timeline_classification"
            }
        ]"#;
        let res = self
            .client
            .post(COURSES_URL)
            .query(&[
                (
                    "info",
                    "core_course_get_enrolled_courses_by_timeline_classification",
                ),
                ("sesskey", &self.sesskey),
            ])
            .body(BODY)
            .header(::reqwest::header::CONTENT_TYPE, "application/json")
            .send()
            .unwrap();

        #[derive(Deserialize)]
        struct Courses {
            courses: Vec<Course>,
        }

        #[derive(Deserialize)]
        struct Res {
            data: Courses,
        }

        let [Res { data }] = res.json::<[Res; 1]>().expect("error parsing response");
        data.courses
    }

    pub fn get_grade_sheet(&self) -> GradeSheet {
        const GRADE_URL: &str = "https://mybk.hcmut.edu.vn/stinfo/grade/ajax_grade";
        let res = self
            .client
            .post(GRADE_URL)
            .form(&[("_token", &self.stinfo_token)])
            .send()
            .expect("grade error");

        #[derive(Deserialize, Debug)]
        struct SemesterGrade {
            diem: GradeSheet,
        }

        let map = res.json::<HashMap<String, SemesterGrade>>().unwrap();

        map.into_iter().flat_map(|(_, v)| v.diem).collect()
    }
}

impl Drop for Client {
    fn drop(&mut self) {
        const LOGOUT_URL: &str = "https://sso.hcmut.edu.vn/cas/logout";
        self.client.get(LOGOUT_URL).send().expect("log out failed");
    }
}