Skip to content

Client

The Client class is the main entry point for all data queries. It is composed of feature mixins, each handling one domain of EAMS data.

create_client

tju.client.create_client

create_client(*args, **kwargs) -> Client
Source code in src/tju/client/__init__.py
def create_client(*args, **kwargs) -> Client:
    session = Session(*args, **kwargs)
    return Client(session=session)

Client

tju.client.Client

Bases: ScheduleMixin, ProfileMixin, CourseMixin, ExamMixin, ScoreMixin, ClassroomMixin, BaseClient

client for tju

Source code in src/tju/client/__init__.py
class Client(
    ScheduleMixin, ProfileMixin, CourseMixin, ExamMixin, ScoreMixin, ClassroomMixin, BaseClient
):
    """
    client for tju
    """

    _session: Session

    def __init__(self, session: Session):
        super().__init__()
        self._session = session

    @property
    def stu_id(self) -> str:
        """Get student id from current session"""
        if "stu_id" not in self._session._cache:
            home_html = self._session.get(HOME_URL_PATH).text
            home_info = re.findall(r'class="personal-name">\s(.*)\((\d+)\)', home_html)

            if len(home_info) == 0:
                raise HtmlParseError("HTML parse error")

            self._session._cache["stu_name"] = home_info[0][0]
            self._session._cache["stu_id"] = home_info[0][1]
        return self._session._cache["stu_id"]

    @property
    def stu_name(self) -> str:
        """Get student name from current session"""
        if "stu_name" not in self._session._cache:
            home_html = self._session.get(HOME_URL_PATH).text
            home_info = re.findall(r'class="personal-name">\s(.*)\((\d+)\)', home_html)

            if len(home_info) == 0:
                raise HtmlParseError("HTML parse error")

            self._session._cache["stu_name"] = home_info[0][0]
            self._session._cache["stu_id"] = home_info[0][1]
        return self._session._cache["stu_name"]

    @property
    def semester(self) -> str:
        if "semester" not in self._session._cache:
            self._session._cache["semester"] = get_current_semester()
        return self._session._cache["semester"]

    @property
    def stu_type(self) -> StuType:
        if "is_gs" not in self._session._cache:
            id_html = self._session.post(ID_URL_PATH, params={"entityId": ""}).text
            self._session._cache["is_gs"] = "研究" in id_html
            self._session._cache["has_minor"] = "辅修" in id_html
        return (
            StuType.GRADUATE if self._session._cache["is_gs"] else StuType.UNDERGRADUATE
        )

    @property
    def has_minor(self) -> bool:
        if "is_gs" not in self._session._cache:
            id_html = self._session.post(ID_URL_PATH, params={"entityId": ""}).text
            self._session._cache["is_gs"] = "研究" in id_html
            self._session._cache["has_minor"] = "辅修" in id_html
        return self._session._cache["has_minor"]

stu_id property

stu_id: str

Get student id from current session

stu_name property

stu_name: str

Get student name from current session

Mixins

tju.client.api.ScheduleMixin

Bases: BaseClient

personal schedule

Source code in src/tju/client/api/schedule.py
class ScheduleMixin(BaseClient):
    """
    personal schedule
    """

    def __init__(self):
        super().__init__()

    def schedule(
        self,
        semester: str | None = None,
        query_minor: bool = False,
        query_class: bool = False,
        **kwargs,
    ) -> Results[Course]:
        """
        self course table
        """
        is_gs = self.stu_type == StuType.GRADUATE
        has_minor = self.has_minor

        if not has_minor and query_minor:
            raise DataError("No minor classes")

        if semester is None:
            semester = self.semester
        if semester not in SEMESTER:
            raise DataError(f"Semester {semester} not found")
        semester_id = SEMESTER[semester]

        if is_gs:
            project_id = 22  # graduate
        elif has_minor and query_minor:
            project_id = 2  # minor
        else:
            project_id = 1  # major

        if not is_gs:
            self._session.get(
                COURSETABLE_INDEX_URL_PATH, params={"projectId": project_id}, **kwargs
            )
            time.sleep(0.1)

        index_html = self._session.get(
            COURSETABLE_GET_URL_PATH, params={"projectId": project_id}, **kwargs
        ).text
        ids_list = re.findall('"ids","([^"]+)"', index_html)
        if len(ids_list) == 0:
            raise HtmlParseError("Cannot find ids")
        ids = ids_list[0]
        time.sleep(0.1)

        schedule_html = self._session.post(
            COURSETABLE_URL_PATH,
            params={
                "ignoreHead": "1",
                "setting.kind": "std" if not query_class else "class",
                "startWeek": "",
                "semester.id": semester_id,
                "ids": ids,
            },
            **kwargs,
        ).text

        try:
            schedule_dict = parse_schedule(schedule_html)
        except IndexError as exc:
            raise HtmlParseError from exc

        schedule = Schedule()
        schedule.load(data=schedule_dict)

        return schedule

schedule

schedule(semester: str | None = None, query_minor: bool = False, query_class: bool = False, **kwargs) -> Results[Course]

self course table

Source code in src/tju/client/api/schedule.py
def schedule(
    self,
    semester: str | None = None,
    query_minor: bool = False,
    query_class: bool = False,
    **kwargs,
) -> Results[Course]:
    """
    self course table
    """
    is_gs = self.stu_type == StuType.GRADUATE
    has_minor = self.has_minor

    if not has_minor and query_minor:
        raise DataError("No minor classes")

    if semester is None:
        semester = self.semester
    if semester not in SEMESTER:
        raise DataError(f"Semester {semester} not found")
    semester_id = SEMESTER[semester]

    if is_gs:
        project_id = 22  # graduate
    elif has_minor and query_minor:
        project_id = 2  # minor
    else:
        project_id = 1  # major

    if not is_gs:
        self._session.get(
            COURSETABLE_INDEX_URL_PATH, params={"projectId": project_id}, **kwargs
        )
        time.sleep(0.1)

    index_html = self._session.get(
        COURSETABLE_GET_URL_PATH, params={"projectId": project_id}, **kwargs
    ).text
    ids_list = re.findall('"ids","([^"]+)"', index_html)
    if len(ids_list) == 0:
        raise HtmlParseError("Cannot find ids")
    ids = ids_list[0]
    time.sleep(0.1)

    schedule_html = self._session.post(
        COURSETABLE_URL_PATH,
        params={
            "ignoreHead": "1",
            "setting.kind": "std" if not query_class else "class",
            "startWeek": "",
            "semester.id": semester_id,
            "ids": ids,
        },
        **kwargs,
    ).text

    try:
        schedule_dict = parse_schedule(schedule_html)
    except IndexError as exc:
        raise HtmlParseError from exc

    schedule = Schedule()
    schedule.load(data=schedule_dict)

    return schedule

tju.client.api.ProfileMixin

Bases: BaseClient

Source code in src/tju/client/api/profile.py
class ProfileMixin(BaseClient):
    def __init__(self):
        super().__init__()

    @property
    def profile(self) -> Profile:
        """Get the user profile of the current session."""
        if "profile" not in self._session._cache:
            profile_html = self._session.get(PROFILE_URL_PATH).text
            self._session._cache["profile"] = parse_profile(profile_html)
        profile = Profile.Schema().load(self._session._cache["profile"])
        if not isinstance(profile, Profile):
            raise DataError("Invalid profile")
        return profile

profile property

profile: Profile

Get the user profile of the current session.

tju.client.api.CourseMixin

Bases: BaseClient

Source code in src/tju/client/api/course.py
class CourseMixin(BaseClient):
    def __init__(self):
        super().__init__()

    def query_courses(
        self,
        stu_type: StuType | int | None = None,
        semester: str | None = None,
        page_no: int | None = None,
        page_size: int | None = None,
        **kwargs,
    ):
        """
        public course lib
        """
        if stu_type is None:
            stu_type = self.stu_type
        if stu_type == StuType.UNDERGRADUATE or stu_type == 1:
            project_id = 1
        elif stu_type == StuType.GRADUATE or stu_type == 2:
            project_id = 22
        else:
            raise StuTypeError(f"Invalid student type: {stu_type}")

        if semester is None:
            semester = self.semester
        if semester not in SEMESTER:
            raise DataError(f"Semester {semester} not found")
        semester_id = SEMESTER[semester]

        if page_no is None:
            page_no = 1

        if page_size is None:
            page_size = 10
        elif page_size > 1000:
            page_size = 1000

        # Set project context in the EAMS session before querying the course library.
        # Without this, project 22 (graduate) returns an AuthenticationException on a
        # fresh session because the EAMS default context is project 1 (undergraduate).
        self._session.get(
            COURSETABLE_INDEX_URL_PATH, params={"projectId": project_id}, **kwargs
        )
        time.sleep(0.1)

        course_html = self._session.get(
            COURSELIB_URL_PATH,
            params={
                "lesson.project.id": project_id,
                "lesson.semester.id": semester_id,
                "pageNo": str(page_no),
                "pageSize": str(page_size),
            },
            **kwargs,
        ).text

        try:
            course_dict = parse_course(html=course_html, semester=semester)
        except IndexError:
            raise HtmlParseError from None

        course = CourseLib()
        if "list" in course_dict:
            course.load(data=course_dict["list"])
            course_dict["list"] = course
        return course_dict

    def query_course_info(self, lession_id: str):
        info_html = self._session.get(
            COURSE_INFO_URL_PATH,
            params={"lesson.id": lession_id},
        ).text

        try:
            info_dict = parse_course_info(info_html)
        except IndexError:
            raise HtmlParseError from None

        return info_dict

    def query_syllabus(self, lession_id: str, format: str = "md"):
        """
        public course syllabus
        """
        syllabus_html = self._session.post(
            COURSE_SYLLABUS_URL_PATH,
            params={"lesson.id": lession_id},
        ).text

        if format == "md":
            try:
                syllabus = parse_syllabus(syllabus_html)
            except IndexError:
                raise HtmlParseError from None
        elif format == "html":
            syllabus = syllabus_html
        else:
            raise DataError(f"Invalid format: {format}, only support 'md' or 'html'")

        return syllabus

query_courses

query_courses(stu_type: StuType | int | None = None, semester: str | None = None, page_no: int | None = None, page_size: int | None = None, **kwargs)

public course lib

Source code in src/tju/client/api/course.py
def query_courses(
    self,
    stu_type: StuType | int | None = None,
    semester: str | None = None,
    page_no: int | None = None,
    page_size: int | None = None,
    **kwargs,
):
    """
    public course lib
    """
    if stu_type is None:
        stu_type = self.stu_type
    if stu_type == StuType.UNDERGRADUATE or stu_type == 1:
        project_id = 1
    elif stu_type == StuType.GRADUATE or stu_type == 2:
        project_id = 22
    else:
        raise StuTypeError(f"Invalid student type: {stu_type}")

    if semester is None:
        semester = self.semester
    if semester not in SEMESTER:
        raise DataError(f"Semester {semester} not found")
    semester_id = SEMESTER[semester]

    if page_no is None:
        page_no = 1

    if page_size is None:
        page_size = 10
    elif page_size > 1000:
        page_size = 1000

    # Set project context in the EAMS session before querying the course library.
    # Without this, project 22 (graduate) returns an AuthenticationException on a
    # fresh session because the EAMS default context is project 1 (undergraduate).
    self._session.get(
        COURSETABLE_INDEX_URL_PATH, params={"projectId": project_id}, **kwargs
    )
    time.sleep(0.1)

    course_html = self._session.get(
        COURSELIB_URL_PATH,
        params={
            "lesson.project.id": project_id,
            "lesson.semester.id": semester_id,
            "pageNo": str(page_no),
            "pageSize": str(page_size),
        },
        **kwargs,
    ).text

    try:
        course_dict = parse_course(html=course_html, semester=semester)
    except IndexError:
        raise HtmlParseError from None

    course = CourseLib()
    if "list" in course_dict:
        course.load(data=course_dict["list"])
        course_dict["list"] = course
    return course_dict

query_syllabus

query_syllabus(lession_id: str, format: str = 'md')

public course syllabus

Source code in src/tju/client/api/course.py
def query_syllabus(self, lession_id: str, format: str = "md"):
    """
    public course syllabus
    """
    syllabus_html = self._session.post(
        COURSE_SYLLABUS_URL_PATH,
        params={"lesson.id": lession_id},
    ).text

    if format == "md":
        try:
            syllabus = parse_syllabus(syllabus_html)
        except IndexError:
            raise HtmlParseError from None
    elif format == "html":
        syllabus = syllabus_html
    else:
        raise DataError(f"Invalid format: {format}, only support 'md' or 'html'")

    return syllabus

tju.client.api.ExamMixin

Bases: BaseClient

exam

Source code in src/tju/client/api/exam.py
class ExamMixin(BaseClient):
    """
    exam
    """

    def __init__(self): ...

    def exam(
        self, semester: str | None = None, query_minor: bool = False, **kwargs
    ) -> list:
        """
        exam
        """
        has_minor = self.has_minor

        if not has_minor and query_minor:
            raise DataError("No minor exam")

        if semester is None:
            semester = self.semester
        if semester not in SEMESTER:
            raise DataError(f"Semester {semester} not found")
        semester_id = SEMESTER[semester]

        exam_post_html = self._session.post(
            EXAM_POST_URL_PATH,
            data={
                "semester.id": semester_id,
            },
            **kwargs,
        ).text

        try:
            exam_batch_id = parse_exam_batch_id(exam_post_html)
        except IndexError:
            raise HtmlParseError from None

        exam_html = self._session.get(
            EXAM_URL_PATH,
            params={
                "examBatch.id": exam_batch_id,
            },
        ).text

        try:
            exam_list = parse_exam(exam_html)
        except IndexError:
            raise HtmlParseError from None

        exams = Exams()
        exams.load(data=exam_list)

        return exams

exam

exam(semester: str | None = None, query_minor: bool = False, **kwargs) -> list

exam

Source code in src/tju/client/api/exam.py
def exam(
    self, semester: str | None = None, query_minor: bool = False, **kwargs
) -> list:
    """
    exam
    """
    has_minor = self.has_minor

    if not has_minor and query_minor:
        raise DataError("No minor exam")

    if semester is None:
        semester = self.semester
    if semester not in SEMESTER:
        raise DataError(f"Semester {semester} not found")
    semester_id = SEMESTER[semester]

    exam_post_html = self._session.post(
        EXAM_POST_URL_PATH,
        data={
            "semester.id": semester_id,
        },
        **kwargs,
    ).text

    try:
        exam_batch_id = parse_exam_batch_id(exam_post_html)
    except IndexError:
        raise HtmlParseError from None

    exam_html = self._session.get(
        EXAM_URL_PATH,
        params={
            "examBatch.id": exam_batch_id,
        },
    ).text

    try:
        exam_list = parse_exam(exam_html)
    except IndexError:
        raise HtmlParseError from None

    exams = Exams()
    exams.load(data=exam_list)

    return exams

tju.client.api.ScoreMixin

Bases: BaseClient

score

Source code in src/tju/client/api/score.py
class ScoreMixin(BaseClient):
    """
    score
    """

    def __init__(self): ...

    def score(self, semester: str | None = None):
        """
        score
        """
        is_gs = self.stu_type == StuType.GRADUATE
        project_id = 22 if is_gs else 1

        semester_id = None
        if semester is not None:
            if semester not in SEMESTER:
                raise DataError(f"Semester {semester} not found")
            else:
                semester_id = SEMESTER[semester]

        if semester_id is None:
            self._session.get(
                COURSETABLE_INDEX_URL_PATH, params={"projectId": project_id}
            )
            score_html = self._session.get(
                SCORE_HISTORY_URL_PATH,
                params={
                    "projectType": "MAJOR",  # ug not empty
                },
            ).text
        else:
            score_html = self._session.get(
                SCORE_SEARCH_URL_PATH,
                params={
                    "semesterId": semester_id,
                    "projectType": "MAJOR",  # can be empty (ug)
                },
            ).text
        try:
            score_dict = parse_score(html=score_html)
        except IndexError:
            raise HtmlParseError from None

        score_summary = GSScoreSummarys() if is_gs else UGScoreSummarys()
        score = GSScores() if is_gs else UGScores()

        if "summary" in score_dict:
            score_summary.load(data=score_dict["summary"])
            score_dict["summary"] = score_summary
        if "list" in score_dict:
            score.load(data=score_dict["list"])
            score_dict["list"] = score
        return score_dict

    def exp_score(self, semester: str | None = None):
        """
        experiments score
        """

        if semester is None:
            semester = self.semester
        if semester not in SEMESTER:
            raise DataError(f"Semester {semester} not found")
        semester_id = SEMESTER[semester]

        score_html = self._session.post(
            SCORE_EXP_URL_PATH,
            params={
                "semester.id": semester_id,
            },
        ).text

        try:
            score_list = parse_score_exp(html=score_html)
        except IndexError:
            raise HtmlParseError from None

        score = ExpScores()
        score.load(data=score_list)
        return score

score

score(semester: str | None = None)

score

Source code in src/tju/client/api/score.py
def score(self, semester: str | None = None):
    """
    score
    """
    is_gs = self.stu_type == StuType.GRADUATE
    project_id = 22 if is_gs else 1

    semester_id = None
    if semester is not None:
        if semester not in SEMESTER:
            raise DataError(f"Semester {semester} not found")
        else:
            semester_id = SEMESTER[semester]

    if semester_id is None:
        self._session.get(
            COURSETABLE_INDEX_URL_PATH, params={"projectId": project_id}
        )
        score_html = self._session.get(
            SCORE_HISTORY_URL_PATH,
            params={
                "projectType": "MAJOR",  # ug not empty
            },
        ).text
    else:
        score_html = self._session.get(
            SCORE_SEARCH_URL_PATH,
            params={
                "semesterId": semester_id,
                "projectType": "MAJOR",  # can be empty (ug)
            },
        ).text
    try:
        score_dict = parse_score(html=score_html)
    except IndexError:
        raise HtmlParseError from None

    score_summary = GSScoreSummarys() if is_gs else UGScoreSummarys()
    score = GSScores() if is_gs else UGScores()

    if "summary" in score_dict:
        score_summary.load(data=score_dict["summary"])
        score_dict["summary"] = score_summary
    if "list" in score_dict:
        score.load(data=score_dict["list"])
        score_dict["list"] = score
    return score_dict

exp_score

exp_score(semester: str | None = None)

experiments score

Source code in src/tju/client/api/score.py
def exp_score(self, semester: str | None = None):
    """
    experiments score
    """

    if semester is None:
        semester = self.semester
    if semester not in SEMESTER:
        raise DataError(f"Semester {semester} not found")
    semester_id = SEMESTER[semester]

    score_html = self._session.post(
        SCORE_EXP_URL_PATH,
        params={
            "semester.id": semester_id,
        },
    ).text

    try:
        score_list = parse_score_exp(html=score_html)
    except IndexError:
        raise HtmlParseError from None

    score = ExpScores()
    score.load(data=score_list)
    return score

tju.client.api.ClassroomMixin

Bases: BaseClient

Free classroom search (空闲教室查询).

Source code in src/tju/client/api/classroom.py
class ClassroomMixin(BaseClient):
    """Free classroom search (空闲教室查询)."""

    def __init__(self): ...

    def free_classrooms(
        self,
        date_begin: str,
        date_end: str | None = None,
        time_begin: str = "1",
        time_end: str = "12",
        by_unit: bool = True,
        campus_id: str | None = None,
        building_id: str | None = None,
        room_type_id: str | None = None,
        seats: int | None = None,
        name: str | None = None,
        cycle_type: int = 1,
        cycle_count: int = 1,
        **kwargs,
    ) -> FreeClassrooms:
        """
        Search for free classrooms on a given date/time range.

        Args:
            date_begin:    Start date in ``YYYY-MM-DD`` format (required).
            date_end:      End date; defaults to ``date_begin`` (single-day search).
            time_begin:    Start of the requested slot. Integer class-unit (1–12) when
                           ``by_unit=True`` (default); ``HH:MM`` clock time when ``False``.
            time_end:      End of the requested slot (same format as ``time_begin``).
            by_unit:       ``True`` — search by class-unit numbers (小节, default);
                           ``False`` — search by clock time (``HH:MM``).
            campus_id:     Campus filter: ``"2"`` 卫津路 / ``"3"`` 北洋园 / ``"4"`` all.
            building_id:   Building filter (optional; depends on campus).
            room_type_id:  Room type: ``"2"`` 普通教室 / ``"7"`` 多媒体 / etc.
            seats:         Minimum seat count filter (optional).
            name:          Classroom name substring filter (optional).
            cycle_type:    Recurrence unit: ``1`` daily (default), ``2`` weekly.
            cycle_count:   Number of recurrence cycles (default ``1``).
        """
        if date_end is None:
            date_end = date_begin

        project_id = 22 if self.stu_type == StuType.GRADUATE else 1

        # The classroom search module requires an active project context in the session.
        self._session.get(
            COURSETABLE_INDEX_URL_PATH,
            params={"projectId": project_id},
            **kwargs,
        )
        self._session.get(FREE_SELECTION_URL_PATH, **kwargs)

        search_html = self._session.post(
            FREE_SEARCH_URL_PATH,
            data={
                "seats": str(seats) if seats is not None else "",
                "classroom.name": name or "",
                "classroom.type.id": str(room_type_id) if room_type_id is not None else "",
                "classroom.campus.id": str(campus_id) if campus_id is not None else "",
                "classroom.building.id": str(building_id) if building_id is not None else "",
                "cycleTime.cycleType": str(cycle_type),
                "cycleTime.cycleCount": str(cycle_count),
                "cycleTime.dateBegin": date_begin,
                "cycleTime.dateEnd": date_end,
                "roomApplyTimeType": "0" if by_unit else "1",
                "timeBegin": str(time_begin),
                "timeEnd": str(time_end),
            },
            **kwargs,
        ).text

        try:
            classroom_list = parse_free_classroom(search_html)
        except IndexError:
            raise HtmlParseError from None

        classrooms = FreeClassrooms()
        classrooms.load(data=classroom_list)
        return classrooms

free_classrooms

free_classrooms(date_begin: str, date_end: str | None = None, time_begin: str = '1', time_end: str = '12', by_unit: bool = True, campus_id: str | None = None, building_id: str | None = None, room_type_id: str | None = None, seats: int | None = None, name: str | None = None, cycle_type: int = 1, cycle_count: int = 1, **kwargs) -> FreeClassrooms

Search for free classrooms on a given date/time range.

Parameters:

Name Type Description Default
date_begin str

Start date in YYYY-MM-DD format (required).

required
date_end str | None

End date; defaults to date_begin (single-day search).

None
time_begin str

Start of the requested slot. Integer class-unit (1–12) when by_unit=True (default); HH:MM clock time when False.

'1'
time_end str

End of the requested slot (same format as time_begin).

'12'
by_unit bool

True — search by class-unit numbers (小节, default); False — search by clock time (HH:MM).

True
campus_id str | None

Campus filter: "2" 卫津路 / "3" 北洋园 / "4" all.

None
building_id str | None

Building filter (optional; depends on campus).

None
room_type_id str | None

Room type: "2" 普通教室 / "7" 多媒体 / etc.

None
seats int | None

Minimum seat count filter (optional).

None
name str | None

Classroom name substring filter (optional).

None
cycle_type int

Recurrence unit: 1 daily (default), 2 weekly.

1
cycle_count int

Number of recurrence cycles (default 1).

1
Source code in src/tju/client/api/classroom.py
def free_classrooms(
    self,
    date_begin: str,
    date_end: str | None = None,
    time_begin: str = "1",
    time_end: str = "12",
    by_unit: bool = True,
    campus_id: str | None = None,
    building_id: str | None = None,
    room_type_id: str | None = None,
    seats: int | None = None,
    name: str | None = None,
    cycle_type: int = 1,
    cycle_count: int = 1,
    **kwargs,
) -> FreeClassrooms:
    """
    Search for free classrooms on a given date/time range.

    Args:
        date_begin:    Start date in ``YYYY-MM-DD`` format (required).
        date_end:      End date; defaults to ``date_begin`` (single-day search).
        time_begin:    Start of the requested slot. Integer class-unit (1–12) when
                       ``by_unit=True`` (default); ``HH:MM`` clock time when ``False``.
        time_end:      End of the requested slot (same format as ``time_begin``).
        by_unit:       ``True`` — search by class-unit numbers (小节, default);
                       ``False`` — search by clock time (``HH:MM``).
        campus_id:     Campus filter: ``"2"`` 卫津路 / ``"3"`` 北洋园 / ``"4"`` all.
        building_id:   Building filter (optional; depends on campus).
        room_type_id:  Room type: ``"2"`` 普通教室 / ``"7"`` 多媒体 / etc.
        seats:         Minimum seat count filter (optional).
        name:          Classroom name substring filter (optional).
        cycle_type:    Recurrence unit: ``1`` daily (default), ``2`` weekly.
        cycle_count:   Number of recurrence cycles (default ``1``).
    """
    if date_end is None:
        date_end = date_begin

    project_id = 22 if self.stu_type == StuType.GRADUATE else 1

    # The classroom search module requires an active project context in the session.
    self._session.get(
        COURSETABLE_INDEX_URL_PATH,
        params={"projectId": project_id},
        **kwargs,
    )
    self._session.get(FREE_SELECTION_URL_PATH, **kwargs)

    search_html = self._session.post(
        FREE_SEARCH_URL_PATH,
        data={
            "seats": str(seats) if seats is not None else "",
            "classroom.name": name or "",
            "classroom.type.id": str(room_type_id) if room_type_id is not None else "",
            "classroom.campus.id": str(campus_id) if campus_id is not None else "",
            "classroom.building.id": str(building_id) if building_id is not None else "",
            "cycleTime.cycleType": str(cycle_type),
            "cycleTime.cycleCount": str(cycle_count),
            "cycleTime.dateBegin": date_begin,
            "cycleTime.dateEnd": date_end,
            "roomApplyTimeType": "0" if by_unit else "1",
            "timeBegin": str(time_begin),
            "timeEnd": str(time_end),
        },
        **kwargs,
    ).text

    try:
        classroom_list = parse_free_classroom(search_html)
    except IndexError:
        raise HtmlParseError from None

    classrooms = FreeClassrooms()
    classrooms.load(data=classroom_list)
    return classrooms