diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d858c1d --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +FILE_PATH=test.wakeup_schedule +XIAOAI_URL=https://i.ai.mi.com/course-multi/table?ctId=1&userId=2&deviceId=3&sourceName=course-app-browser diff --git a/.gitignore b/.gitignore index b6e4761..d2b7560 100644 --- a/.gitignore +++ b/.gitignore @@ -127,3 +127,6 @@ dmypy.json # Pyre type checker .pyre/ + +# data +*.wakeup_schedule diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..6bac125 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,16 @@ +{ + // 使用 IntelliSense 了解相关属性。 + // 悬停以查看现有属性的描述。 + // 欲了解更多信息,请访问: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Python: main", + "type": "python", + "request": "launch", + "program": "main.py", + "console": "integratedTerminal", + "justMyCode": true + } + ] +} \ No newline at end of file diff --git a/README.md b/README.md index c36d175..c5dab26 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,29 @@ # WakeUp2XiaoAi + A python script to convert WakeUp json to Xiao Ai Schedule. + +将 WakeUp 课程表的数据导入到小爱课程表,解决小爱课程表教务导入过于破烂的问题 + +# 使用方法 + +## 导出课程表 + +将 WakeUp 中已经导入好的课程表使用右上方分享按钮 `导出为备份(可导入)文件` 放置于 `main.py` 同目录下,假设此文件名称为:`test.wakeup_schedule` + +## 建立课程表 + +在小爱课程表建立一个课程表,设置好上课时间、总周数、课程节数、课表时间 + +## 导出编辑链接 + +在建好的课程表设置页,选择 `PC编辑课表` 导出编辑链接,然后复制链接,假设此链接为:`https://i.ai.mi.com/h5/precache/ai-schedule/#/pceditor?token=1` + +## 运行脚本 + +`python3 main.py` + +然后输入 `test.wakeup_schedule` + +然后输入 `https://i.ai.mi.com/h5/precache/ai-schedule/#/pceditor?token=1` + +回到小爱课程表,发现同步完成。 diff --git a/config.py b/config.py new file mode 100644 index 0000000..1c1f590 --- /dev/null +++ b/config.py @@ -0,0 +1,7 @@ +import os +from dotenv import load_dotenv + +load_dotenv() + +FILE_PATH = os.getenv('FILE_PATH') +XIAOAI_URL = os.getenv("XIAOAI_URL") diff --git a/defs/wakeup.py b/defs/wakeup.py new file mode 100644 index 0000000..a1dc12a --- /dev/null +++ b/defs/wakeup.py @@ -0,0 +1,65 @@ +import json +from pathlib import Path +from typing import Dict, List +from models.wakeup import Course, CourseInfo +from models.xiaoai import AiCourse + + +class WakeUp: + def __init__(self, file_path: str) -> None: + while True: + self.file_path = file_path or input("请输入文件路径 (例如 test.wakeup_schedule ): ") + path = Path(self.file_path) + if not path.exists(): + print("文件不存在, 请重新输入路径.") + file_path = None + continue + break + self.old_data: List[Course] = [] + self.old_data_info: List[CourseInfo] = [] + self.old_data_info_map: Dict[int, CourseInfo] = {} + self.weeks_map: Dict[str, List[int]] = {} + self.new_data: List[AiCourse] = [] + self.new_data_map: Dict[str, List[AiCourse]] = {} + + def load_data(self) -> None: + """ 解析 wakeup 备份文件数据 """ + with open(self.file_path, "r", encoding="utf-8") as f: + lines = f.readlines() + self.old_data_info = [CourseInfo(**i) for i in json.loads(lines[3])] + for i in self.old_data_info: + self.old_data_info_map[i.id] = i + self.old_data = [Course(**i) for i in json.loads(lines[4])] + for i in self.old_data: + data = self.weeks_map.get(i.key, [].copy()) + data.extend(i.weeks) + self.weeks_map[i.key] = data + # 移除重复 id + temp_keys, old_data = [], [] + for i in self.old_data: + if i.key in temp_keys: + continue + old_data.append(i) + temp_keys.append(i.key) + self.old_data = old_data + + def get_weeks(self, temp: Course) -> str: + """ 获取当前课程周数 """ + return ",".join(list(map(str, self.weeks_map[temp.key]))) + + def convert_data(self) -> List[AiCourse]: + """ 转换为小爱课程表格式 """ + for i in self.old_data: + old_data_info = self.old_data_info_map[i.id] + self.new_data.append( + AiCourse( + name=old_data_info.courseName, + position=i.room, + teacher=i.teacher, + weeks=self.get_weeks(i), + day=i.day, + style=old_data_info.style, + sections=i.sections, + ) + ) + return self.new_data diff --git a/defs/xiaoai.py b/defs/xiaoai.py new file mode 100644 index 0000000..8654f3e --- /dev/null +++ b/defs/xiaoai.py @@ -0,0 +1,81 @@ +import base64 +import re +from datetime import datetime +from typing import Dict +from httpx import AsyncClient +from models.xiaoai import AiCourse, AiCourseInfo, Table, TabCourse + + +class XiaoAi: + HEADERS = { + 'authority': 'i.ai.mi.com', + 'accept': 'application/json', + 'accept-language': 'zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6,hu;q=0.5', + 'access-control-allow-origin': 'true', + 'content-type': 'application/json', + 'origin': 'https://i.ai.mi.com', + 'referer': 'https://i.ai.mi.com/h5/precache/ai-schedule/', + 'sec-ch-ua': '"Chromium";v="110", "Not A(Brand";v="24", "Microsoft Edge";v="110"', + 'sec-ch-ua-mobile': '?0', + 'sec-ch-ua-platform': '"Windows"', + 'sec-fetch-dest': 'empty', + 'sec-fetch-mode': 'cors', + 'sec-fetch-site': 'same-origin', + 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.0.0 Safari/537.36 Edg/110.0.1587.63', + } + URL = "https://i.ai.mi.com/course-multi/courseInfo" + TABLE_URL = "https://i.ai.mi.com/course-multi/table" + + def __init__(self, url_temp: str) -> None: + while True: + url = url_temp or input("请输入包含 token 的小爱课程表链接 : ") + token = re.search(r"token=(.*)", url) + if not token: + print("链接错误, 请重新输入。") + url_temp = None + continue + decoded_text = base64.b64decode(token[1]).decode('utf-8').split("%26") + self.user_id = int(decoded_text[0]) + self.device_id = decoded_text[1] + timestamp = int(decoded_text[2]) / 1000 + self.expire_time = datetime.fromtimestamp(timestamp) + self.ct_id = int(decoded_text[3]) + break + self.client: AsyncClient = AsyncClient(headers=self.HEADERS) + self.table: Table = None + + async def get_all_course(self) -> Table: + """ 获取课程表数据 """ + params = { + 'ctId': str(self.ct_id), + 'userId': str(self.user_id), + 'deviceId': self.device_id, + 'sourceName': 'course-app-browser', + } + data = await self.client.get(self.TABLE_URL, params=params) + json_data = data.json() + if json_data["code"] != 0: + raise ValueError("课程表不存在或者链接已过期") + self.table = Table(**json_data["data"]) + return self.table + + async def delete_course(self, course: TabCourse) -> None: + """ 删除某个课程 """ + json_data = { + 'ctId': self.ct_id, + 'userId': self.user_id, + 'deviceId': self.device_id, + 'cId': course.id, + 'sourceName': 'course-app-browser', + } + await self.client.request("DELETE", self.URL, json=json_data) + + async def delete_all_course(self) -> None: + """ 删除课程表数据 """ + for i in self.table.courses: + await self.delete_course(i) + + async def add_course(self, course: AiCourse) -> None: + """ 添加课程 """ + info = AiCourseInfo(userId=self.user_id, deviceId=self.device_id, ctId=self.ct_id, course=course) + await self.client.post(self.URL, json=info.dict()) diff --git a/main.py b/main.py new file mode 100644 index 0000000..1141cbc --- /dev/null +++ b/main.py @@ -0,0 +1,27 @@ +import asyncio +import warnings +from config import FILE_PATH, XIAOAI_URL +from defs.wakeup import WakeUp +from defs.xiaoai import XiaoAi + +warnings.filterwarnings("ignore", category=DeprecationWarning) + + +async def main(): + wakeup = WakeUp(FILE_PATH) + xiaoai = XiaoAi(XIAOAI_URL) + print(f"链接过期时间:{xiaoai.expire_time.strftime('%Y-%m-%d %H:%M:%S')}") + wakeup.load_data() + data = wakeup.convert_data() + table = await xiaoai.get_all_course() + print(f"开始处理课程表 - {table.name}") + print("开始删除课程表数据") + await xiaoai.delete_all_course() + print("开始导入课程表数据") + for i in data: + await xiaoai.add_course(i) + + +if __name__ == '__main__': + loop = asyncio.get_event_loop() + loop.run_until_complete(main()) diff --git a/models/wakeup.py b/models/wakeup.py new file mode 100644 index 0000000..7442dd3 --- /dev/null +++ b/models/wakeup.py @@ -0,0 +1,36 @@ +from typing import List +from pydantic import BaseModel + + +class Course(BaseModel): + id: int + day: int # 周几 + startWeek: int # 开始的周次 + endWeek: int # 停止的周次 + startNode: int # 开始的节次 + step: int # 有多少节 + room: str # 位置 + teacher: str # 老师 + + @property + def sections(self) -> str: + data = map(str, list(range(self.startNode, self.startNode + self.step))) + return ",".join(data) + + @property + def key(self) -> str: + return f"{self.id}_{self.day}_{self.startNode}_{self.step}" + + @property + def weeks(self) -> List[int]: + return list(range(self.startWeek, self.endWeek + 1)) + + +class CourseInfo(BaseModel): + id: int + color: str + courseName: str + + @property + def style(self) -> str: + return '{"color":"#FFFFFF", "background":"#' + self.color[3:] + '"}' diff --git a/models/xiaoai.py b/models/xiaoai.py new file mode 100644 index 0000000..8b3cf33 --- /dev/null +++ b/models/xiaoai.py @@ -0,0 +1,30 @@ +from typing import List +from pydantic import BaseModel + + +class AiCourse(BaseModel): + name: str + position: str + teacher: str + weeks: str + day: int + style: str + sections: str + + +class TabCourse(AiCourse): + id: int + + +class AiCourseInfo(BaseModel): + userId: int + deviceId: str + ctId: int + course: AiCourse + sourceName: str = "course-app-browser" + + +class Table(BaseModel): + id: int + name: str + courses: List[TabCourse] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..a984a1c --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +pydantic==1.10.5 +httpx==0.23.3 +python-dotenv==1.0.0