diff --git a/downloader.py b/downloader.py new file mode 100644 index 0000000..2bc68f7 --- /dev/null +++ b/downloader.py @@ -0,0 +1,209 @@ +import os +from tqdm import tqdm + + +from thu_learn_lib import LearnHelper +from thu_learn_lib import ty as types +from thu_learn_lib.utils import slugify + + +class Downloader(LearnHelper): + prefix: str = "" + file_size_limit: int = None + sync_docs: bool = True + sync_work: bool = True + sync_submit: bool = True + + def __init__( + self, + prefix: str = "", + file_size_limit: int = None, + sync_docs: bool = True, + sync_work: bool = True, + sync_submit: bool = True, + ) -> None: + super().__init__() + if prefix: + self.prefix = prefix + else: + self.prefix = os.path.join(os.getcwd(), slugify("learn")) + self.file_size_limit = file_size_limit + self.sync_docs = sync_docs + self.sync_work = sync_work + self.sync_submit = sync_submit + + def Download(self, url: str, prefix: str, filename: str) -> bool: + os.makedirs(prefix, exist_ok=True) + filename = slugify(filename) + response = self.get(url=url, stream=True) + file_size = int(response.headers.get("content-length", 0)) + if self.file_size_limit: + if file_size > self.file_size_limit * 1024: + return False + chunk_size = 8192 # 8KB + with tqdm( + desc=filename, + total=file_size, + unit="B", + ascii=True, + unit_scale=True, + dynamic_ncols=True, + ) as bar: + with open(os.path.join(prefix, filename), "wb") as file: + for chunck in response.iter_content(chunk_size): + file.write(chunck) + bar.update(len(chunck)) + return True + + def SyncSemester( + self, semester_id: str, course_type: types.CourseType = types.CourseType.STUDENT + ) -> bool: + print(f"Syncing Semester {semester_id} ......") + course_list = self.get_course_list( + semester_id=semester_id, course_type=course_type + ) + for course in course_list: + self.SyncCourse( + course=course, semester_id=semester_id, + ) + + def SyncCourse(self, course: types.CourseInfo, semester_id: str) -> bool: + file_list = self.get_file_list( + course_id=course.id, course_type=course.course_type + ) + print( + f"Syncing Course {course.course_number} {course.name} {course.english_name} ......" + ) + if self.sync_docs: + for file in file_list: + self.SyncFile(file, semester_id=semester_id, course=course) + if self.sync_work: + homework_list = self.get_homework_list(course_id=course.id) + for homework in homework_list: + self.SyncHomework( + homework=homework, semester_id=semester_id, course=course + ) + + def SyncFile( + self, file: types.File, semester_id: str, course: types.CourseInfo + ) -> bool: + prefix = os.path.join( + self.prefix, + slugify(course.english_name), + slugify("documents"), + slugify(file.clazz), + ) + filename = slugify(file.title) + slugify( + f".{file.file_type}" if file.file_type else "" + ) + self.Download(url=file.download_url, prefix=prefix, filename=filename) + return True + + def SyncHomework( + self, homework: types.Homework, semester_id: str, course: types.CourseInfo + ) -> bool: + prefix = os.path.join( + self.prefix, + slugify(course.english_name), + slugify("work"), + slugify(homework.title), + ) + os.makedirs(prefix, exist_ok=True) + lines = [] + lines.append(f"## Contents and Requirements") + lines.append(f"") + lines.append(f"### Title") + lines.append(f"") + lines.append(f"{homework.title}") + lines.append(f"") + lines.append(f"### Description") + lines.append(f"") + lines.append(f"{homework.description}") + lines.append(f"") + if homework.attachment: + filename = slugify( + f"{homework.title}{os.path.splitext(homework.attachment.name)[-1]}" + ) + self.Download( + url=homework.attachment.download_url, prefix=prefix, filename=filename, + ) + lines.append(f"### Attach.") + lines.append(f"") + lines.append(f"[{homework.attachment.name}]({filename})") + lines.append(f"") + lines.append(f"### ANS") + lines.append(f"") + lines.append(f"{homework.answer_content}") + lines.append(f"") + if homework.answer_attachment: + filename = slugify( + f"ans-{homework.title}{os.path.splitext(homework.answer_attachment.name)[-1]}" + ) + self.Download( + url=homework.answer_attachment.download_url, + prefix=prefix, + filename=filename, + ) + lines.append(f"### Attach.") + lines.append(f"") + lines.append(f"[{homework.answer_attachment.name}]({filename})") + lines.append(f"") + lines.append(f"### Deadline (GMT+8)") + lines.append(f"") + lines.append(f"{homework.deadline}") + lines.append(f"") + if self.sync_submit: + lines.append(f"## My coursework submitted") + lines.append(f"") + lines.append(f"### Content") + lines.append(f"") + lines.append(f"{homework.submitted_content}") + lines.append(f"") + if homework.submitted_attachment: + filename = slugify( + f"submit-{homework.title}{os.path.splitext(homework.submitted_attachment.name)[-1]}" + ) + self.Download( + url=homework.submitted_attachment.download_url, + prefix=prefix, + filename=filename, + ) + lines.append(f"### Attach.") + lines.append(f"") + lines.append(f"[{homework.submitted_attachment.name}]({filename})") + lines.append(f"") + lines.append(f"## Instructors' comments") + lines.append(f"") + lines.append(f"### By") + lines.append(f"") + lines.append(f"{homework.grader_name}") + lines.append(f"") + lines.append(f"### Date") + lines.append(f"") + lines.append(f"{homework.grade_time}") + lines.append(f"") + lines.append(f"### Grade") + lines.append(f"") + lines.append(f"{homework.grade}") + lines.append(f"") + lines.append(f"### Comment") + lines.append(f"") + lines.append(f"{homework.grade_content}") + lines.append(f"") + if homework.grade_attachment: + filename = slugify( + f"comment-{homework.title}{os.path.splitext(homework.grade_attachment.name)[-1]}" + ) + self.Download( + url=homework.grade_attachment.download_url, + prefix=prefix, + filename=filename, + ) + lines.append(f"### Attach.") + lines.append(f"") + lines.append(f"[{homework.grade_attachment.name}]({filename})") + lines.append(f"") + lines = [line + "\n" for line in lines] + filename = slugify("README.md") + with open(os.path.join(prefix, filename), "w") as file: + file.writelines(lines) diff --git a/main.py b/main.py new file mode 100644 index 0000000..60cca64 --- /dev/null +++ b/main.py @@ -0,0 +1,72 @@ +import argparse + + +from downloader import Downloader + + +parser = argparse.ArgumentParser( + add_help="Automatically download files from THU Web Learning." +) +parser.add_argument("--username", required=True) +parser.add_argument("--password", required=True) +parser.add_argument( + "--semesters", + nargs="+", + default=None, + required=False, + help="semesters to be synced. If you want to sync all semesters, do not pass this argument", +) +parser.add_argument( + "--prefix", default=None, required=False, help="location to save downloaded files", +) +parser.add_argument( + "--file_size_limit", + default=None, + type=float, + required=False, + help="files exceed limit will not be downloaded", +) +parser.add_argument( + "--no_sync_docs", + action="store_true", + default=False, + required=False, + help="pass this argument to skip syncing files", +) +parser.add_argument( + "--no_sync_work", + action="store_true", + default=False, + required=False, + help="pass this argument to skip syncing homework", +) +parser.add_argument( + "--no_sync_submit", + action="store_true", + default=False, + required=False, + help="pass this argument to skip syncing your submission & grade and everything personal", +) + + +def main(args: argparse.Namespace): + downloader = Downloader( + prefix=args.prefix, + file_size_limit=args.file_size_limit, + sync_docs=(not args.no_sync_docs), + sync_work=(not args.no_sync_work), + sync_submit=(not args.no_sync_submit), + ) + downloader.login(username=args.username, password=args.password) + semester_id_list = downloader.get_semester_id_list() + semesters = args.semesters if args.semesters else semester_id_list + for semester in semesters: + if semester in semester_id_list: + downloader.SyncSemester(semester_id=semester) + else: + print(f"{semester} not found") + + +if __name__ == "__main__": + args = parser.parse_args() + main(args) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..38b4e19 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +beautifulsoup4~=4.11.1 +requests~=2.27.1 +tqdm~=4.64.0 +python-slugify~=6.1.2 diff --git a/thu_learn_lib/__init__.py b/thu_learn_lib/__init__.py new file mode 100644 index 0000000..74cd8a8 --- /dev/null +++ b/thu_learn_lib/__init__.py @@ -0,0 +1,3 @@ +from . import helper + +LearnHelper = helper.LearnHelper diff --git a/thu_learn_lib/helper.py b/thu_learn_lib/helper.py new file mode 100644 index 0000000..e5a80b0 --- /dev/null +++ b/thu_learn_lib/helper.py @@ -0,0 +1,317 @@ +import urllib +import bs4 +import datetime +import re +import requests + + +from . import ty as types +from . import urls + + +class LearnHelper(requests.Session): + def __init__(self) -> None: + super().__init__() + + @property + def token(self) -> str: + return self.cookies["XSRF-TOKEN"] if "XSRF-TOKEN" in self.cookies else None + + def get_with_token(self, url: str, params: dict = None) -> requests.Response: + if params is None: + params = {"_csrf": self.token} + else: + params["_csrf"] = self.token + return self.get(url=url, params=params) + + def login(self, username: str, password: str) -> bool: + response = self.get(urls.LEARN_PREFIX) + soup = bs4.BeautifulSoup(response.text, features="html.parser") + form = soup.find( + name="form", attrs={"class": "w", "id": "loginForm", "method": "post"} + ) + + payload = urls.id_login_form_data(username=username, password=password).params + response = self.post(url=form["action"], data=payload) + soup = bs4.BeautifulSoup(response.text, features="html.parser") + a = soup.find(name="a") + pattern = rf"{urls.LEARN_PREFIX}/f/login.do\?status=(\w*)&ticket=(\w*)" + match = re.match(pattern=pattern, string=a["href"]) + self.success = match.group(1) == "SUCCESS" + self.ticket = match.group(2) + + response = self.get(a["href"]) + soup = bs4.BeautifulSoup(response.text, features="html.parser") + script = soup.find(name="script", type="text/javaScript") + pattern = r"\s*window.location=\"/b/j_spring_security_thauth_roaming_entry\?ticket=(\w*)\";\s*" + match = re.match(pattern=pattern, string=script.text) + self.ticket = match.group(1) + + request = urls.learn_auth_roam(ticket=self.ticket) + response = self.get(url=request.url, params=request.params) + + request = urls.learn_student_course_list_page() + response = self.get(url=request.url, params=request.params) + + print( + f"User {username} login successfully" + if self.success + else f"User {username} login failed!" + ) + return self.success + + def logout(self) -> None: + request = urls.learn_logout() + response = self.post(url=request.url) + + def get_semester_id_list(self) -> list: + request = urls.learn_semester_list() + response = self.get_with_token(url=request.url, params=request.params) + json = response.json() + return [x for x in json if x] + + def get_current_semester(self) -> types.SemesterInfo: + request = urls.learn_current_semester() + response = self.get_with_token(url=request.url, params=request.params) + json = response.json() + result = json["result"] + return types.SemesterInfo( + id=result["id"], + start_date=datetime.datetime.strptime(result["kssj"], r"%Y-%m-%d").date(), + end_date=datetime.datetime.strptime(result["jssj"], r"%Y-%m-%d").date(), + start_year=int(result["xnxq"][0:4]), + end_year=int(result["xnxq"][5:9]), + type=int(result["xnxq"][10:]), + ) + + def get_course_list( + self, semester_id: str, course_type: types.CourseType = types.CourseType.STUDENT + ) -> list[types.CourseInfo]: + request = urls.learn_course_list(semester_id, course_type) + response = self.get_with_token(url=request.url, params=request.params) + json = response.json() + result = json["resultList"] + + def mapper(course: dict): + request = urls.learn_course_time_location(course["wlkcid"]) + response = self.get_with_token(request.url, request.params) + return types.CourseInfo( + id=course["wlkcid"], + name=course["kcm"], + english_name=course["ywkcm"], + time_and_location=response.json(), + url=str(urls.learn_course_url(course["wlkcid"], course_type)), + teacher_name=course["jsm"] + if course["jsm"] + else "", # teacher can not fetch this + teacher_number=course["jsh"], + course_number=course["kch"], + course_index=int( + course["kxh"] + ), # course["kxh"] could be string (teacher mode) or number (student mode) + course_type=course_type, + ) + + courses = list(map(mapper, result)) + return courses + + def get_file_list( + self, course_id: str, course_type: types.CourseType = types.CourseType.STUDENT + ) -> list[types.File]: + request = urls.learn_file_classify(course_id=course_id) + response = self.get_with_token(url=request.url, params=request.params) + json = response.json() + records = json["object"]["rows"] + clazz = {} + for record in records: + clazz[record["kjflid"]] = record["bt"] # 课件分类 ID, 标题 + + request = urls.learn_file_list(course_id=course_id, course_type=course_type) + response = self.get_with_token(request.url, request.params) + json = response.json() + result = [] + if course_type == types.CourseType.STUDENT: + result = json["object"] + else: # teacher + result = json["object"]["resultList"] + + def mapper(file: dict) -> types.File: + title: str = file["bt"] + download_url: str = urls.learn_file_download( + file_id=file["wjid"] + if course_type == types.CourseType.STUDENT + else file["id"], + course_type=course_type, + course_id=course_id, + ) + preview_url = None + return types.File( + id=file["wjid"], # 文件 ID + title=file["bt"], # 标题 + description=file["ms"], # 描述 + raw_size=file["wjdx"], # 文件大小 + size=file["fileSize"], + upload_time=datetime.datetime.strptime( + file["scsj"], r"%Y-%m-%d %H:%M" + ), # 上传时间 + download_url=str(download_url), + preview_url=str(preview_url), + is_new=file["isNew"], + marked_important=file["sfqd"] == 1, # 是否强调 + visit_count=file["llcs"] or 0, # 浏览次数 + download_count=file["xzcs"] or 0, # 下载次数 + file_type=file["wjlx"], # 文件类型 + remote_file=types.RemoteFile( + id=file["wjid"], # 文件 ID + name=title, + download_url=str(download_url), + preview_url=str(preview_url), + size=file["fileSize"], + ), + clazz=clazz[file["kjflid"]], # 课件分类 ID + ) + + return list(map(mapper, result)) + + def get_homework_list( + self, course_id: str, course_type: types.CourseType = types.CourseType.STUDENT + ) -> list[types.Homework]: + result = [] + request = urls.learn_homework_list_new(course_id=course_id) + result += self.get_homework_list_at_url( + request=request, status=types.HomeworkStatus(submitted=False, graded=False) + ) + request = urls.learn_homework_list_submitted(course_id=course_id) + result += self.get_homework_list_at_url( + request=request, status=types.HomeworkStatus(submitted=True, graded=False) + ) + request = urls.learn_homework_list_graded(course_id=course_id) + result += self.get_homework_list_at_url( + request=request, status=types.HomeworkStatus(submitted=True, graded=True) + ) + + return result + + def get_homework_list_at_url( + self, request: urls.URL, status: types.HomeworkStatus + ) -> list[types.Homework]: + response = self.get_with_token(url=request.url, params=request.params) + json = response.json() + + result = json["object"]["aaData"] + + def mapper(work: dict) -> types.Homework: + detail = self.parse_homework_detail( + course_id=work["wlkcid"], # 课程 ID + homework_id=work["zyid"], # 作业 ID + student_homework_id=work["xszyid"], # 学生作业 ID + ) + + return types.Homework( + id=work["zyid"], # 作业 ID + student_homework_id=work["xszyid"], # 学生作业 ID + title=work["bt"], # 标题 + url=str( + urls.learn_homework_detail( + course_id=work["wlkcid"], # 课程 ID + homework_id=work["zyid"], # 作业 ID + student_homework_id=work["xszyid"], # 学生作业 ID + ) + ), + deadline=work["jzsj"], # 截止时间 + submit_url=urls.learn_homework_submit( + work["wlkcid"], work["xszyid"] # 课程 ID, 学生作业 ID + ), + submit_time=work["scsj"], # 上传时间 + grade=work["cj"], # 成绩 + grader_name=work["jsm"], # 教师名 + grade_content=work["pynr"], # 批阅内容 + grade_time=work["pysj"], # 批阅时间 + submitted=status.submitted, + graded=status.graded, + description=detail.description, + answer_content=detail.answer_content, + submitted_content=detail.submitted_content, + attachment=detail.attachment, + answer_attachment=detail.answer_attachment, + submitted_attachment=detail.submitted_attachment, + grade_attachment=detail.grade_attachment, + ) + + return list(map(mapper, result)) + + def parse_homework_detail( + self, course_id: str, homework_id: str, student_homework_id: str + ) -> types.HomeworkDetail: + request = urls.learn_homework_detail( + course_id=course_id, + homework_id=homework_id, + student_homework_id=student_homework_id, + ) + response = self.get_with_token(request.url, request.params) + text = response.text + soup = bs4.BeautifulSoup(markup=text, features="html.parser") + + div_list_calendar_clearfix = soup.find_all( + name="div", attrs={"class": "list calendar clearfix"} + ) + div_fl_right = sum( + [ + div.find_all(name="div", attrs={"class": "fl right"}) + for div in div_list_calendar_clearfix + ], + [], + ) + div_c55 = sum( + [div.find_all(name="div", attrs={"class": "c55"}) for div in div_fl_right], + [], + ) + description = div_c55[0].getText() + answer_content = div_c55[1].getText() + div_box = soup.find_all(name="div", attrs={"class": "boxbox"}) + div_box = div_box[1] + div_right = div_box.find_all(name="div", attrs={"class": "right"}) + submitted_content = div_right = div_right[2].getText() + + div_list_fujian_clearfix = soup.find_all( + name="div", attrs={"class": "list fujian clearfix"} + ) + return types.HomeworkDetail( + description=description.strip(), + answer_content=answer_content.strip(), + submitted_content=submitted_content.strip(), + attachment=self.parse_homework_file(div_list_fujian_clearfix[0]) + if len(div_list_fujian_clearfix) > 0 + else None, + answer_attachment=self.parse_homework_file(div_list_fujian_clearfix[1]) + if len(div_list_fujian_clearfix) > 1 + else None, + submitted_attachment=self.parse_homework_file(div_list_fujian_clearfix[2]) + if len(div_list_fujian_clearfix) > 2 + else None, + grade_attachment=self.parse_homework_file(div_list_fujian_clearfix[3]) + if len(div_list_fujian_clearfix) > 3 + else None, + ) + + def parse_homework_file(self, div) -> types.RemoteFile: + ftitle = div.find(name="span", attrs={"class": "ftitle"}) or div.find( + name="span", attrs={"class", "ft"} + ) + if not ftitle: + return None + a = ftitle.find(name="a") + size = div.find(name="span", attrs={"class": "color_999"}) + size = size.getText() + params = dict(urllib.parse.parse_qsl(urllib.parse.urlsplit(a["href"]).query)) + attachment_id = params["fileId"] + download_url = urls.LEARN_PREFIX + ( + params["downloadUrl"] if "downloadUrl" in params else a["href"] + ) + return types.RemoteFile( + id=attachment_id, + name=a.getText(), + download_url=download_url, + preview_url=None, + size=size.strip(), + ) diff --git a/thu_learn_lib/ty.py b/thu_learn_lib/ty.py new file mode 100644 index 0000000..20dfd26 --- /dev/null +++ b/thu_learn_lib/ty.py @@ -0,0 +1,200 @@ +import dataclasses +import datetime +import enum +import typing + + +@dataclasses.dataclass +class Credential: + username: str + password: str + + +class FailReason(enum.Enum): + NO_CREDENTIAL = "no credential provided" + ERROR_FETCH_FROM_ID = "could not fetch ticket from id.tsinghua.edu.cn" + BAD_CREDENTIAL = "bad credential" + ERROR_ROAMING = "could not roam to learn.tsinghua.edu.cn" + NOT_LOGGED_IN = "not logged in or login timeout" + NOT_IMPLEMENTED = "not implemented" + INVALID_RESPONSE = "invalid response" + UNEXPECTED_STATUS = "unexpected status" + + +class ApiError(RuntimeError): + reason: FailReason + extra: typing.Any = None + + +class SemesterType(enum.Enum): + FALL = "Autumn Term" + SPRING = "Spring Term" + SUMMER = "Summer Term" + UNKNOWN = "" + + +class ContentType(enum.Enum): + NOTIFICATION = "notification" + FILE = "file" + HOMEWORK = "homework" + DISCUSSION = "discussion" + QUESTION = "question" + + +@dataclasses.dataclass +class SemesterInfo: + id: str + start_date: datetime.datetime + end_date: datetime.datetime + start_year: int + end_year: int + type: SemesterType + + +class CourseType(enum.Enum): + STUDENT = "student" + TEACHER = "teacher" + + +@dataclasses.dataclass +class CourseInfo: + id: str + name: str + english_name: str + time_and_location: list[str] + url: str + teacher_name: str + teacher_number: str + course_number: str + course_index: int + course_type: CourseType + + +@dataclasses.dataclass +class RemoteFile: + id: str + name: str + download_url: str + preview_url: str + size: str + + +@dataclasses.dataclass +class Notification: + id: str + title: str + content: str + has_read: bool + url: str + marked_important: bool + publish_time: datetime.datetime + publisher: str + # notification detail + attachment: RemoteFile = None + + +@dataclasses.dataclass +class File: + id: str + raw_size: int # size in byte + size: str # inaccurate size description (like '1M') + title: str + description: str + upload_time: datetime.datetime + download_url: str # for teachers, this url will not initiate download directly + preview_url: str # preview is not supported on all types of files, check before use + is_new: bool + marked_important: bool + visit_count: int + download_count: int + file_type: str + remote_file: RemoteFile # for compatibility + + clazz: str + + +@dataclasses.dataclass +class HomeworkStatus: + submitted: bool = None + graded: bool = None + + +@dataclasses.dataclass +class HomeworkDetail: + description: str = None + attachment: RemoteFile = None # attachment from teacher + answer_content: str = None # answer from teacher + answer_attachment: RemoteFile = None + submitted_content: str = None # submitted content from student + submitted_attachment: RemoteFile = None + grade_attachment: RemoteFile = None # grade from teacher + + +@dataclasses.dataclass +class Homework(HomeworkStatus, HomeworkDetail): + # status + # submitted: bool + # graded: bool + # homework + id: str = None + student_homework_id: str = None + title: str = None + deadline: datetime.datetime = None + url: str = None + submit_url: str = None + submit_time: datetime.datetime = None + grade: int = None + grade_level: str = None # some homework has levels but not grades, like A/B/.../F + grade_time: datetime.datetime = None + grader_name: str = None + grade_content: str = None + # detail + # description: str = None + # attachment: RemoteFile = None # attachment from teacher + # answer_content: str = None # answer from teacher + # answer_attachment: RemoteFile = None + # submitted_content: str = None # submitted content from student + # submitted_attachment: RemoteFile = None + # grade_attachment: RemoteFile = None # grade from teacher + + +@dataclasses.dataclass +class Discussion: + # base + id: str + title: str + publisher_name: str + publish_date: datetime.datetime + last_replier_name: str + last_reply_time: datetime.datetime + visit_count: int + reply_count: int + # discussion + url: str + board_id: str + + +@dataclasses.dataclass +class Question: + # discussion base + id: str + title: str + publisher_name: str + publish_date: datetime.datetime + last_replier_name: str + last_reply_time: datetime.datetime + visit_count: int + reply_count: int + # question + url: str + question: str + + +@dataclasses.dataclass +class CalendarEvent: + location: str + status: str + start_time: str + end_time: str + date: str + course_name: str diff --git a/thu_learn_lib/urls.py b/thu_learn_lib/urls.py new file mode 100644 index 0000000..1cdf5e7 --- /dev/null +++ b/thu_learn_lib/urls.py @@ -0,0 +1,316 @@ +import dataclasses + +from . import ty as types +from . import utils + + +LEARN_PREFIX = "https://learn.tsinghua.edu.cn" +REGISTRAR_PREFIX = "https://zhjw.cic.tsinghua.edu.cn" + + +MAX_SIZE = 200 + + +@dataclasses.dataclass +class URL: + url: str = None + params: dict = None + + def __str__(self) -> str: + if self.params: + return ( + self.url + + "?" + + "&".join([f"{key}={value}" for key, value in self.params.items()]) + ) + + +def id_login() -> URL: + return URL( + url="https://id.tsinghua.edu.cn/do/off/ui/auth/login/post/bb5df85216504820be7bba2b0ae1535b/0?/login.do" + ) + + +def id_login_form_data(username: str, password: str) -> URL: + credential = {} + credential["i_user"] = username + credential["i_pass"] = password + credential["atOnce"] = True + return URL(params=credential) + + +def learn_auth_roam(ticket: str) -> URL: + return URL( + url=f"{LEARN_PREFIX}/b/j_spring_security_thauth_roaming_entry", + params={"ticket": ticket}, + ) + + +def learn_logout() -> URL: + return URL(url=f"{LEARN_PREFIX}/f/j_spring_security_logout") + + +def learn_student_course_list_page() -> URL: + return URL(url=f"{LEARN_PREFIX}/f/wlxt/index/course/student/") + + +def learn_semester_list() -> URL: + return URL(url=f"{LEARN_PREFIX}/b/wlxt/kc/v_wlkc_xs_xktjb_coassb/queryxnxq") + + +def learn_current_semester() -> URL: + return URL(url=f"{LEARN_PREFIX}/b/kc/zhjw_v_code_xnxq/getCurrentAndNextSemester") + + +def learn_course_list(semester: str, course_type: types.CourseType) -> URL: + if course_type == types.CourseType.STUDENT: + return URL( + url=f"{LEARN_PREFIX}/b/wlxt/kc/v_wlkc_xs_xkb_kcb_extend/student/loadCourseBySemesterId/{semester}" + ) + else: + return URL( + url=f"{LEARN_PREFIX}/b/kc/v_wlkc_kcb/queryAsorCoCourseList/{semester}/0" + ) + + +def learn_course_url(course_id: str, course_type: str) -> URL: + return URL( + url=f"{LEARN_PREFIX}/f/wlxt/index/course/{course_type}/course", + params={"wlkcid": course_id}, + ) + + +def learn_course_time_location(course_id: str) -> URL: + return URL( + url=f"{LEARN_PREFIX}/b/kc/v_wlkc_xk_sjddb/detail", params={"id": course_id} + ) + + +def learn_teacher_course_url(course_id: str) -> URL: + return URL( + url=f"{LEARN_PREFIX}/f/wlxt/index/course/teacher/course", + params={"wlkcid": course_id}, + ) + + +def learn_file_list(course_id: str, course_type: types.CourseType) -> URL: + if course_type == types.CourseType.STUDENT: + return URL( + url=f"{LEARN_PREFIX}/b/wlxt/kj/wlkc_kjxxb/student/kjxxbByWlkcidAndSizeForStudent", + params={"wlkcid": course_id, "size": MAX_SIZE}, + ) + else: + return URL( + url=f"{LEARN_PREFIX}/b/wlxt/kj/v_kjxxb_wjwjb/teacher/queryByWlkcid", + params={"wlkcid": course_id, "size": MAX_SIZE}, + ) + + +def learn_file_classify(course_id: str) -> URL: + return URL( + url=f"{LEARN_PREFIX}/b/wlxt/kj/wlkc_kjflb/student/pageList", + params={"wlkcid": course_id}, + ) + + +def learn_file_download(file_id: str, course_type: str, course_id: str) -> URL: + if course_type == types.CourseType.STUDENT: + return URL( + url=f"{LEARN_PREFIX}/b/wlxt/kj/wlkc_kjxxb/student/downloadFile", + params={"sfgk": 0, "wjid": file_id}, + ) + else: + return URL( + url=f"{LEARN_PREFIX}/f/wlxt/kj/wlkc_kjxxb/teacher/beforeView", + params={"id": file_id, "wlkcid": course_id}, + ) + + +def learn_file_preview( + type: types.ContentType, + file_id: str, + course_type: types.CourseType, + first_page_only: bool = False, +) -> URL: + return URL( + url=f"{LEARN_PREFIX}/f/wlxt/kc/wj_wjb/{course_type}/beforePlay", + params={ + "wjid": file_id, + "mk": utils.get_mk_from_type(type), + "browser": -1, + "sfgk": 0, + "pageType": "first" if first_page_only else "all", + }, + ) + + +def learn_notification_list(course_id: str, course_type: types.CourseType) -> URL: + if course_type == types.CourseType.STUDENT: + return URL( + url=f"{LEARN_PREFIX}/b/wlxt/kcgg/wlkc_ggb/student/kcggListXs", + params={"wlkcid": course_id, "size": MAX_SIZE}, + ) + else: + return URL( + url=f"{LEARN_PREFIX}/b/wlxt/kcgg/wlkc_ggb/teacher/kcggList", + params={"wlkcid": course_id, "size": MAX_SIZE}, + ) + + +def learn_notification_detail( + course_id: str, notification_id: str, course_type: types.CourseType +) -> URL: + if course_type == types.CourseType.STUDENT: + return URL( + url=f"{LEARN_PREFIX}/f/wlxt/kcgg/wlkc_ggb/student/beforeViewXs", + params={"wlkcid": course_id, "id": notification_id}, + ) + else: + return URL( + url=f"{LEARN_PREFIX}/f/wlxt/kcgg/wlkc_ggb/teacher/beforeViewJs", + params={"wlkcid": course_id, "id": notification_id}, + ) + + +def learn_notification_edit(course_type: types.CourseType) -> URL: + return URL(url=f"{LEARN_PREFIX}/b/wlxt/kcgg/wlkc_ggb/{course_type}/editKcgg") + + +def learn_homework_list_source(course_id: str) -> dict[str]: + return [ + { + "url": learn_homework_list_new(course_id), + "status": {"submitted": False, "graded": False,}, + }, + { + "url": learn_homework_list_submitted(course_id), + "status": {"submitted": True, "graded": False,}, + }, + { + "url": learn_homework_list_graded(course_id), + "status": {"submitted": True, "graded": True,}, + }, + ] + + +def learn_homework_list_new(course_id: str) -> URL: + return URL( + url=f"{LEARN_PREFIX}/b/wlxt/kczy/zy/student/index/zyListWj", + params={"wlkcid": course_id, "size": MAX_SIZE}, + ) + + +def learn_homework_list_submitted(course_id: str) -> URL: + return URL( + url=f"{LEARN_PREFIX}/b/wlxt/kczy/zy/student/index/zyListYjwg", + params={"wlkcid": course_id, "size": MAX_SIZE}, + ) + + +def learn_homework_list_graded(course_id: str) -> URL: + return URL( + url=f"{LEARN_PREFIX}/b/wlxt/kczy/zy/student/index/zyListYpg", + params={"wlkcid": course_id, "size": MAX_SIZE}, + ) + + +def learn_homework_detail( + course_id: str, homework_id: str, student_homework_id: str +) -> URL: + return URL( + url=f"{LEARN_PREFIX}/f/wlxt/kczy/zy/student/viewCj", + params={ + "wlkcid": course_id, + "zyid": homework_id, + "xszyid": student_homework_id, + }, + ) + + +def learn_homework_download(course_id: str, attachment_id: str) -> URL: + return URL( + url=f"{LEARN_PREFIX}/b/wlxt/kczy/zy/student/downloadFile/{course_id}/{attachment_id}" + ) + + +def learn_homework_submit(course_id: str, student_homework_id: str) -> URL: + return URL( + url=f"{LEARN_PREFIX}/f/wlxt/kczy/zy/student/tijiao", + params={"wlkcid": course_id, "xszyid": student_homework_id}, + ) + + +def learn_discussion_list(course_id: str, course_type: types.CourseType) -> URL: + return URL( + url=f"{LEARN_PREFIX}/b/wlxt/bbs/bbs_tltb/{course_type}/kctlList", + params={"wlkcid": course_id, "size": MAX_SIZE}, + ) + + +def learn_discussion_detail( + course_id: str, + board_id: str, + discussion_id: str, + course_type: types.CourseType, + tab_id=1, +) -> URL: + return URL( + url=f"{LEARN_PREFIX}/f/wlxt/bbs/bbs_tltb/{course_type}/viewTlById", + params={ + "wlkcid": course_id, + "id": discussion_id, + "tabbh": tab_id, + "bqid": board_id, + }, + ) + + +def learn_question_list_answered(course_id: str, course_type: types.CourseType) -> URL: + return URL( + url=f"{LEARN_PREFIX}/b/wlxt/bbs/bbs_tltb/{course_type}/kcdyList", + params={"wlkcid": course_id, "size": MAX_SIZE}, + ) + + +def learn_question_detail( + course_id: str, question_id: str, course_type: types.CourseType +) -> URL: + if course_type == types.CourseType.STUDENT: + return URL( + url=f"{LEARN_PREFIX}/f/wlxt/bbs/bbs_kcdy/student/viewDyById", + params={"wlkcid": course_id, "id": question_id}, + ) + else: + return URL( + url=f"{LEARN_PREFIX}/f/wlxt/bbs/bbs_kcdy/teacher/beforeEditDy", + params={"wlkcid": course_id, "id": question_id}, + ) + + +def registrar_ticket_form_data() -> URL: + return URL(params={"appId": "ALL_ZHJW"}) + + +def registrar_ticket() -> URL: + return URL(url=f"{LEARN_PREFIX}/b/wlxt/common/auth/gnt") + + +def registrar_auth(ticket: str) -> URL: + return URL( + url=f"{REGISTRAR_PREFIX}/j_acegi_login.do", + params={"url": "/", "ticket": ticket}, + ) + + +def registrar_calendar( + start_date: str, end_date: str, graduate: bool = False, callback_name="unknown" +) -> URL: + return URL( + url=f"{REGISTRAR_PREFIX}/jxmh_out.do", + params={ + "m": ("yjs" if graduate else "bks") + "_jxrl_all", + "p_start_date": start_date, + "p_end_date": end_date, + "jsoncallback": callback_name, + }, + ) diff --git a/thu_learn_lib/utils.py b/thu_learn_lib/utils.py new file mode 100644 index 0000000..6e1df37 --- /dev/null +++ b/thu_learn_lib/utils.py @@ -0,0 +1,35 @@ +from . import ty as types +import slugify as slug + + +def slugify(text: str) -> str: + return ".".join( + [ + slug.slugify(text=segment, word_boundary=True, allow_unicode=True) + for segment in text.split(".") + ] + ) + + +def parse_semester_type(n: int) -> types.SemesterType: + if n == 1: + return types.SemesterType.FALL + elif n == 2: + return types.SemesterType.SPRING + elif n == 3: + return types.SemesterType.SUMMER + else: + return types.SemesterType.UNKNOWN + + +CONTENT_TYPE_MK_MAP: dict[types.ContentType, str] = { + types.ContentType.NOTIFICATION: "kcgg", # 课程公告 + types.ContentType.FILE: "kcwj", # 课程文件 + types.ContentType.HOMEWORK: "kczy", # 课程作业 + types.ContentType.DISCUSSION: "", + types.ContentType.QUESTION: "", +} + + +def get_mk_from_type(type: types.ContentType) -> str: + return "mk_" + CONTENT_TYPE_MK_MAP.get(type, "UNKNOWN")