├── .gitignore ├── demo ├── cookie.png ├── demo.jpg └── demo2.jpg ├── LICENSE ├── README.md ├── main.py ├── httpRequestUtil_contextmanager.py └── coursecalendar.py /.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | /dist 3 | *.spec 4 | *.ics -------------------------------------------------------------------------------- /demo/cookie.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/barryZZJ/ucas_course_to_calendar/HEAD/demo/cookie.png -------------------------------------------------------------------------------- /demo/demo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/barryZZJ/ucas_course_to_calendar/HEAD/demo/demo.jpg -------------------------------------------------------------------------------- /demo/demo2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/barryZZJ/ucas_course_to_calendar/HEAD/demo/demo2.jpg -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Zejun Zhou 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 课表生成手机日历小工具 2 | 3 | [![](https://img.shields.io/github/stars/BarryZZJ/ucas_course_to_calendar.svg)](https://github.com/barryZZJ/ucas_course_to_calendar) 4 | 5 | ## 效果展示 6 | 7 | - 脚本运行过程: 8 | 9 | 10 | 11 | - 导入手机后的效果: 12 | 13 | 14 | 15 | 16 | 17 | ## 简介 18 | 19 | 本脚本会根据课表信息,自动生成`.ics`文件([iCalendar](https://baike.baidu.com/item/iCal/10119576), 一种日历格式标准),安卓与苹果通用,可以直接导入到手机日历。 20 | 21 | ## 使用方法 22 | 23 | ### 获取cookie 24 | 25 | 需要`jwxk.ucas.ac.cn`域名下名为`JSESSIONID`的cookie,获取方法: 26 | 27 | 1. 浏览器进入选课系统 28 | 2. 按`F12`打开开发者工具 29 | 3. 如图(以chrome为例),依次找到`Application - Cookies - SESSION`(注意对应的域名要选`xkcts.ucas.ac.cn`的): 30 | ![](/demo/cookie.png) 31 | 32 | 33 | 34 | ### 运行脚本 35 | 36 | 1. 进入[releases页面](https://github.com/barryZZJ/ucas_course_to_calendar/releases)下载可执行文件; 37 | 2. 启动后根据提示分别输入本学期第一天的年月日,以及SESSION的值。 38 | 可选参数:`-o FILE`,可指定输出文件名,默认为`courses.ics`。 39 | 3. 脚本同目录下获得`courses.ics`文件。 40 | 41 | ### 导入手机 42 | 43 | - **苹果:** 把`.ics`文件发送给手机(必须以**邮件**的方式发送给自己,并用**系统自带的邮件APP**打开),在邮件中打开附件,即可导入。 44 | - **安卓:** 把`.ics`文件发送给手机,并使用系统自带的日历程序打开(如华为系统叫"华为日历"),即可导入。 45 | 46 | ### 提示 47 | 48 | 导入时建议新建一个日历账户,这样可以跟自己手动添加的日程区分开,方便统一隐藏或删除,还能设置不同颜色。 49 | 50 | ## 使用Python运行 51 | 52 | ### 配置环境 53 | 54 | ```sh 55 | pip install requests 56 | pip install beautifulsoup4 57 | ``` 58 | 59 | ### 运行脚本 60 | 61 | ```sh 62 | python3 main.py 63 | ``` 64 | 65 | ## Cahngelog 66 | 67 | `v1.3`: 增加可选参数`-o FILE` 68 | 69 | `v1.2`: 修复了第12节课时长显示出错的BUG。 70 | 71 | `v1.1`: 降低使用门槛,获取用户信息写在了运行脚本之后。 72 | 73 | `v1.0`: 初版 74 | 75 | ## 结语 76 | 77 | [![](https://img.shields.io/github/followers/BarryZZJ.svg?style=social&label=Follow&maxAge=2592000)](https://github.com/barryZZJ) 关注我,方便获取最新脚本动态~ 78 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | import os 2 | import requests as r 3 | from datetime import datetime 4 | from bs4 import BeautifulSoup 5 | import argparse 6 | 7 | from coursecalendar import Calendar 8 | from httpRequestUtil_contextmanager import httpRequest 9 | 10 | parser = argparse.ArgumentParser() 11 | parser.add_argument('--output', '-o', default='courses') 12 | args = parser.parse_args() 13 | 14 | def error(msg): 15 | print(msg) 16 | os.system('pause') 17 | exit() 18 | 19 | firstdayofterm = input("学期第一天的年月日,用'.'隔开,如2024.2.26:") 20 | session = input('SESSION:') 21 | 22 | host = 'https://xkcts.ucas.ac.cn' 23 | url = host + '/courseManage/main' 24 | 25 | s = r.Session() 26 | cookies = { 27 | 'SESSION': session # xkcts.ucas.ac.cn 28 | } 29 | 30 | firstday = datetime(*map(int, firstdayofterm.split('.'))) 31 | calendar = Calendar(firstday) 32 | 33 | with httpRequest(s, url, 'get', cookies=cookies) as resp: 34 | # 解析"已选择课程"表格 35 | soup = BeautifulSoup(resp.content, 'html.parser') 36 | print('正在解析已选课程表格') 37 | table = soup.find('table') 38 | if not table: 39 | error('错误!无法解析已选课程表格,请检查jsessionid是否过期') 40 | for i, tr in enumerate(table.tbody.find_all('tr')): 41 | tds = tr.find_all('td') 42 | courseId = tds[0].a.getText() 43 | a_courseName = tds[1].a 44 | courseName = a_courseName.getText() 45 | courseTimeUrl = host + a_courseName['href'] 46 | teacherName = tds[6].a.getText() 47 | print(f"{i+1:2}" + '. ' + courseId + '\t' + courseName) 48 | with httpRequest(s, courseTimeUrl, 'get') as resp2: 49 | # 解析上课时间 50 | soup2 = BeautifulSoup(resp2.content, 'html.parser') 51 | table2 = soup2.table 52 | if not table2: 53 | print('错误!无法获取课程时间表,暂时跳过。请确认选课系统中是否正确显示。') 54 | continue 55 | trs2 = table2.find_all('tr') 56 | groups = [(trs2[i].td.getText(), trs2[i+1].td.getText(), trs2[i+2].td.getText()) for i in range(0, len(trs2), 3)] 57 | for time, place, week in groups: 58 | calendar.appendCourse(courseId, courseName, time, place, week, teacherName) 59 | 60 | print('解析完成,正在生成文件' + args.output + '.ics') 61 | calendar.to_ics(args.output + '.ics') 62 | print('成功!\n\n通过邮件等方式发送到手机后,即可导入到手机日历,安卓苹果通用。\n导入时建议新建一个日历账户,这样方便统一删除以及颜色区分。\n') 63 | os.system('pause') 64 | 65 | 66 | -------------------------------------------------------------------------------- /httpRequestUtil_contextmanager.py: -------------------------------------------------------------------------------- 1 | from typing import Callable, Union 2 | import requests 3 | from contextlib import contextmanager 4 | 5 | @contextmanager 6 | def httpRequest(session, url, method, payload: dict = None, **params): 7 | """ 8 | Generate a context to automatically send http request, allowing user to focus on response handling. 9 | 10 | Parameters 11 | ---------- 12 | session : requests.Session 13 | Provide a session to be used. 14 | url : str 15 | Full url address with scheme 'http://' or 'https://'. 16 | method : str 17 | Only 'get' or 'post' is supported. 18 | payload : dict, optional 19 | This parameter is a lazy replacement for "params" and "data" (correspond to GET and POST respectively). 20 | **params : 21 | Additional arguments passed to package ``request``, such as headers and cookies. 22 | 23 | Returns 24 | ------- 25 | requests.Response 26 | 27 | Examples 28 | -------- 29 | >>> s = requests.Session() 30 | >>> with httpRequest(s, 'https://www.bilibili.com', 'get', 31 | ... payload={'key': 'data'}, 32 | ... headers={}, 33 | ... cookies={}) as resp: 34 | ... # Handle the response object 35 | ... print(resp.ok) 36 | True 37 | """ 38 | if params is None: 39 | params = {} 40 | 41 | # Set default headers 42 | headers = params.setdefault('headers', {}) 43 | UA = { 44 | 'android': 'Mozilla/5.0 (Linux; Android 10; NOH-AN00 Build/HUAWEINOH-AN00; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/86.0.4240.99 XWEB/3263 MMWEBSDK/20211202 Mobile Safari/537.36 MMWEBID/9462 MicroMessenger/8.0.18.2060(0x28001257) Process/toolsmp WeChat/arm64 Weixin NetType/WIFI Language/zh_CN ABI/arm64', 45 | 'pc': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/104.0.0.0 Safari/537.36' 46 | } 47 | headers.setdefault('User-Agent', 48 | UA['pc'] 49 | ) 50 | 51 | if method not in ['get', 'post']: 52 | raise NotImplementedError('http method', method, 'is not supported!') 53 | 54 | if payload: 55 | # replace key 'payload' with the correct key 56 | payload_key = 'params' if method == 'get' else 'data' 57 | params[payload_key] = payload 58 | 59 | # send the request 60 | yield session.request(method, url, **params) 61 | 62 | -------------------------------------------------------------------------------- /coursecalendar.py: -------------------------------------------------------------------------------- 1 | import re 2 | import uuid 3 | from datetime import datetime, time, timedelta 4 | 5 | 6 | class Calendar: 7 | """ics文件格式: 8 | https://cloud.tencent.com/developer/article/1655829 9 | https://datatracker.ietf.org/doc/html/rfc5545""" 10 | HEADER = """BEGIN:VCALENDAR 11 | PRODID:https://github.com/barryZZJ/ucas_course_to_calendar 12 | VERSION:2.0 13 | CALSCALE:GREGORIAN 14 | METHOD:PUBLISH 15 | CLASS:PUBLIC 16 | BEGIN:VTIMEZONE 17 | TZID:Asia/Shanghai 18 | TZURL:http://tzurl.org/zoneinfo-outlook/Asia/Shanghai 19 | X-LIC-LOCATION:Asia/Shanghai 20 | BEGIN:STANDARD 21 | TZOFFSETFROM:+0800 22 | TZOFFSETTO:+0800 23 | TZNAME:CST 24 | DTSTART:19700101T000000 25 | END:STANDARD 26 | END:VTIMEZONE""".replace(" ", "") # 日历头,replace 用来去掉对齐用的缩进 27 | TAIL = "END:VCALENDAR" 28 | TIMETABLE_ST = [ 29 | '_', 30 | time(8, 30), time(9, 20), time(10, 30), time(11, 20), 31 | time(13, 30), time(14, 20), time(15, 30), time(16, 20), 32 | time(18, 10), time(19, 0), time(20, 10), time(21, 0), 33 | ] 34 | TIMETABLE_ED = [ 35 | '_', 36 | time(9, 20), time(10, 10), time(11, 20), time(12, 10), 37 | time(14, 20), time(15, 10), time(16, 20), time(17, 10), 38 | time(19, 0), time(19, 50), time(21, 0), time(21, 50) 39 | ] 40 | char2int = { "一": 1, "二": 2, "三": 3, "四": 4, "五": 5, "六": 6, "七": 7, "日": 7, "天": 7 } 41 | def __init__(self, first_day_of_term): 42 | self._events = [] 43 | self.first_day = first_day_of_term 44 | self.events = [] 45 | self.pat = re.compile("星期(\S): 第((?:\d+、?)+)节。") 46 | 47 | def appendCourse(self, id, name, time, place, week, teacher): 48 | """示例 49 | time: 星期二: 第1、2节。 50 | place: 教一楼404 51 | week: 2、3、4、5、6、8、9、10、11、12 52 | """ 53 | weekday, numbers = self.pat.match(time).groups() # weekday: 一, numbers: 1、2 54 | day = self.char2int[weekday] # 星期几,int型 55 | numbers = [*map(int, numbers.split('、'))] # 节次,int列表 56 | startTime = self.TIMETABLE_ST[numbers[0]] # 起始时间 (xx:xx) 57 | endTime = self.TIMETABLE_ED[numbers[-1]] # 结束时间 (xx:xx) 58 | weeks = [*map(int, week.split('、'))] # 上课周次,int列表 59 | for w in weeks: 60 | st_delt = timedelta(weeks=w-1, days=day-1, hours=startTime.hour, minutes=startTime.minute) 61 | ed_delt = timedelta(weeks=w-1, days=day-1, hours=endTime.hour, minutes=endTime.minute) 62 | start = self.first_day + st_delt # 起始日期时间 datetime对象 63 | end = self.first_day + ed_delt # 结束日期时间 datetime对象 64 | self._events.append(self._toEvent(id, name, start, end, place, teacher)) 65 | 66 | def _toEvent(self, id, name, start: datetime, end: datetime, location, teacher): 67 | """ 68 | 事件格式:https://datatracker.ietf.org/doc/html/rfc5545#section-3.6.1 69 | 70 | example: 71 | BEGIN:VEVENT 72 | DTSTAMP:20210830T121455Z # 创建时间 73 | DTSTART;TZID=Asia/Shanghai:20211110T142500 # 开始时间 74 | DTEND;TZID=Asia/Shanghai:20211110T180500 # 结束时间 75 | SUMMARY:课程名 76 | LOCATION:教室 77 | DESCRIPTION:课程编码:xxx\n主讲教师:xxx 78 | TRANSP:OPAQUE 79 | ORGANIZER:UCAS 80 | UID:3ad5a81a-0f59-469c-bb37-e07fed6f9d9e 81 | END:VEVENT 82 | """ 83 | 84 | time_format = "{date.year}{date.month:0>2d}{date.day:0>2d}T{date.hour:0>2d}{date.minute:0>2d}{date.second:0>2d}" 85 | res = [] 86 | res += ['BEGIN:VEVENT'] 87 | res += ['DTSTAMP:' + datetime.today().strftime("%Y%m%dT%H%M%SZ")] 88 | res += ['DTSTART;TZID=Asia/Shanghai:' + time_format.format(date=start)] 89 | res += ['DTEND;TZID=Asia/Shanghai:' + time_format.format(date=end)] 90 | res += ['SUMMARY:' + name] 91 | res += ['LOCATION:' + location] 92 | res += ['DESCRIPTION:' + '课程编码: ' + id + r'\n' + '主讲教师: ' + teacher] 93 | res += ['TRANSP:OPAQUE'] 94 | res += ['ORGANIZER:UCAS'] 95 | res += ['UID:' + str(uuid.uuid4())] 96 | res += ['END:VEVENT'] 97 | return '\n'.join(res) 98 | 99 | def to_ics(self, filename): 100 | eventsStr = '\n'.join(self._events) 101 | icsStr = '\n'.join([self.HEADER, eventsStr, self.TAIL]) 102 | with open(filename, "w", encoding="utf8") as f: 103 | f.write(icsStr) --------------------------------------------------------------------------------