├── .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://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 | 
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://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)
--------------------------------------------------------------------------------