├── .gitattributes
├── .gitignore
├── README.md
├── kcl.py
├── kclical.py
└── screenshots
├── calendar_export.png
└── timetable_fetch_kclpy.png
/.gitattributes:
--------------------------------------------------------------------------------
1 | # Auto detect text files and perform LF normalization
2 | * text=auto
3 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.ics
2 | *.fin
3 | .DS_Store
4 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # kcl-timetable (kcl.py & kclical.py)
2 | ## Tools for viewing and exporting your KCL timetable.
3 |
4 | For Python3 - To install dependencies paste the following in your terminal:
5 |
6 | ```
7 | pip3 install crayons
8 | pip3 install requests
9 | pip3 install keyring
10 | pip3 install icalendar
11 | ```
12 | ---
13 |
14 | ### View timetable in terminal: (kcl.py)
15 |
16 | ```
17 | python3 /[path]/[to]/kcl.py
18 | ```
19 |
20 | Log in with your k-number and password - your credentials are stored locally for the next time you run it.
21 |
22 | To add as a terminal command, add it as an alias, e.g. for macOS:
23 | Edit __~/.bash_profile__, with
24 | ```sudo nano ~/.bash_profile```
25 |
26 | then add the the line
27 |
28 | > alias timetable='python3 '/[path]/[to]/kcl.py'
29 |
30 | ---
31 |
32 | 
33 |
34 | ---
35 |
36 | ### iCalendar export: (kclical.py)
37 |
38 | To export your timetable (for the next 60 days) as a .ics file for import in Google and Apple Calendars, run the following script in terminal:
39 |
40 | ```
41 | python3 /[path]/[to]/kclical.py
42 | ```
43 |
44 | Log in with your k-number and password - your password will be stored locally and securely in the keyring.
45 | If you mistype your login, simply delete the 'knumber.fin' file in the script directory and log in again.
46 |
47 | The calendar is exported to __course_schedule.ics__ in the script directory.
48 |
49 | 
50 |
--------------------------------------------------------------------------------
/kcl.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | '''
3 | Fetch Calendar from King's App
4 |
5 | Version 2019-02-25.22
6 |
7 | To Do:
8 | Add command line args for date range, etc.
9 | '''
10 |
11 | import xml.etree.ElementTree as ET
12 | from datetime import datetime
13 | from datetime import timedelta
14 | from getpass import getpass
15 | import sys
16 | import os
17 | import re
18 | import keyring
19 | import requests
20 | import crayons
21 | import argparse
22 |
23 | parser = argparse.ArgumentParser(
24 | description='Fetch your KCL timetable on the command line.')
25 | parser.add_argument('-r', '--reset',
26 | action="store_true", dest="reset",
27 | help="Reset your login credentials", default=False)
28 | parser.add_argument('--reverse',
29 | action="store_true", dest="reverse",
30 | help="Print timetable dates in reverse order", default=False)
31 | parser.add_argument('-d', '--days', nargs='?', default='14', const='const',
32 | help="The number of days to look ahead")
33 |
34 | args = parser.parse_args()
35 |
36 | days = 14
37 | if args.days.isnumeric():
38 | days = int(args.days)
39 | else:
40 | exit("'days' parameter must be int")
41 |
42 | # keyring namespace for this app
43 | SERVICE_ID = 'kcl_timetable'
44 | KNUM_REG_PATTERN = re.compile("^[k|K]\d{6,10}$")
45 |
46 | if (args.reset):
47 | try:
48 | keyring.delete_password(SERVICE_ID, "knumber")
49 | except:
50 | print("No K-Number stored")
51 |
52 | try:
53 | keyring.delete_password(SERVICE_ID, "password")
54 | except:
55 | print("No password stored")
56 |
57 |
58 | knum = keyring.get_password(SERVICE_ID, "knumber")
59 |
60 | if knum == None:
61 | knum = ""
62 | while not re.match(KNUM_REG_PATTERN, knum):
63 | knum = input("Enter a valid K-Number: ")
64 | pwd = getpass("Enter your password: ")
65 | keyring.set_password(SERVICE_ID, "knumber", knum)
66 | keyring.set_password(SERVICE_ID, "password", pwd)
67 |
68 | password = keyring.get_password(SERVICE_ID, "password") # retrieve password
69 |
70 | UNAME = knum
71 | PASSWD = password
72 |
73 | STARTDATE = (datetime.utcnow().replace(
74 | hour=0, minute=0, second=0, microsecond=0)).isoformat()
75 | ENDDATE = (datetime.utcnow() + timedelta(days=days)).isoformat()
76 |
77 | XML_BODY = '''
78 | {}
79 | {}
80 | course_timetable
81 | {}
82 | {}
83 | '''.format(UNAME, PASSWD, STARTDATE, ENDDATE)
84 |
85 | APP_REQ_HEADERS = {
86 | 'Host': 'campusm.kcl.ac.uk',
87 | 'Content-Type': 'application/xml',
88 | 'Accept-Encoding': 'gzip',
89 | 'Pragma': 'no-cache',
90 | 'User-Agent': '''King's%20Mobile/9081458 CFNetwork/978.0.7 Darwin/18.7.0''',
91 | 'Content-Length': '291',
92 | 'Accept': '*/*',
93 | 'Accept-Language': 'en-gb',
94 | 'Authorization': '''Basic YXBwbGljYXRpb25fc2VjX3VzZXI6ZjJnaDUzNDg=''',
95 | 'Cache-Control': 'no-cache',
96 | 'Connection': 'keep-alive'
97 | }
98 |
99 | '''
100 | Authorization: Basic YXBwbGljYXRpb25fc2VjX3VzZXI6ZjJnaDUzNDg=
101 |
102 | This is base64 encoded, for HTTP 'Basic' authentication scheme in headers,
103 | https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Authorization
104 | When decoded it reads:
105 | application_sec_user:f2gh5348
106 |
107 | I'd like to try another user login to see if this token is unique?
108 | '''
109 |
110 | XML_RESPONSE = ""
111 | try:
112 | XML_RESPONSE = requests.post(
113 | 'https://campusm.kcl.ac.uk//kclNewTimetable/services/CampusMUniversityService/retrieveCalendar',
114 | data=XML_BODY, headers=APP_REQ_HEADERS, verify=True).text
115 | except:
116 | try:
117 | XML_RESPONSE = requests.post(
118 | 'https://campusm.kcl.ac.uk//kclNewTimetable/services/CampusMUniversityService/retrieveCalendar',
119 | data=XML_BODY, headers=APP_REQ_HEADERS, verify=False).text
120 | print("Was unable to verify SSL Cert. Disabled 'verify=True' flag for Request")
121 | except:
122 | print("Unable to connect - check internet connection and try again?")
123 | sys.exit(0)
124 |
125 |
126 | ROOT = ET.fromstring(XML_RESPONSE)
127 | CALITEMS = (list(list(ROOT)[0]))
128 |
129 | dates = {}
130 |
131 | for item in CALITEMS:
132 | calentry = {}
133 | for field in list(item):
134 | calentry[field.tag.replace(
135 | '{http://campusm.gw.com/campusm}', '')] = field.text
136 |
137 | date_time_str = calentry['end']
138 | date_time_obj = datetime.fromisoformat(date_time_str)
139 | calentry['end'] = date_time_obj.strftime('%H:%M')
140 |
141 | date_time_str = calentry['start']
142 | date_time_obj = datetime.fromisoformat(date_time_str)
143 | calentry['start'] = date_time_obj.strftime('%H:%M')
144 |
145 | date_key = date_time_obj.strftime("%Y-%m-%d")
146 |
147 | if('/Tut/' in calentry['desc1']):
148 | calentry['type'] = 'Large Group Tut.'
149 | elif('/Discussion/' in calentry['desc1']):
150 | calentry['type'] = 'Discussion'
151 | elif('/Prac/' in calentry['desc1']):
152 | calentry['type'] = 'Practical/Lab'
153 | elif('/Lecture' in calentry['desc1']):
154 | calentry['type'] = 'Lecture'
155 | elif('/SmG/' in calentry['desc1']):
156 | calentry['type'] = 'Small Group Tut.'
157 | elif('/DROP IN/' in calentry['desc1']):
158 | calentry['type'] = 'Drop-In Session'
159 | else:
160 | calentry['type'] = 'Lesson'
161 |
162 | if('/' in calentry['desc1']):
163 | m = re.search(r" \w*\/", calentry['desc1'])
164 | calentry['desc1'] = calentry['desc1'][:m.start()]
165 |
166 | if(calentry['desc2'] == None):
167 | calentry['desc2'] = calentry['desc1']
168 |
169 | dates.setdefault(date_key, []).append(calentry)
170 |
171 | print("\nThis is your timetable for the next {} days:".format(days))
172 |
173 | if len(dates.keys()) < 1:
174 | print("\n[No Events Found]\n")
175 |
176 | for key in sorted(dates.keys(), reverse=args.reverse): # Top to bottom flag
177 | dt = datetime.strptime(key, '%Y-%m-%d')
178 |
179 | daystring = ""
180 | if dt.date() == datetime.today().date():
181 | daystring = " (Today) "
182 | elif dt.date() == (datetime.today().date() + timedelta(days=1)):
183 | daystring = " (Tomorrow) "
184 | elif dt.date() == (datetime.today().date() - timedelta(days=1)):
185 | daystring = " (Yesterday) "
186 | else:
187 | date_str = dt.strftime("%a %d %b %Y")
188 |
189 | date_str = dt.strftime("%a %d %b %Y") + daystring
190 |
191 | print(crayons.white("=============================" +
192 | f"{daystring.center(14, '=')}" + "===============================\n"))
193 |
194 | print(
195 | crayons.green(date_str, bold=True)
196 | )
197 |
198 | events = dates[key]
199 | for event in sorted(events, key=lambda k: k['start']):
200 | if dt.date() <= datetime.today().date() and str(event.get('start', '00:00')) < datetime.utcnow().strftime("%H:%M"):
201 | print(' {} - {}\t\t\t\t{}\n ↳ {} {}\n'.format(
202 | crayons.blue(
203 | event.get('start', 'No Start Time Given'), bold=True),
204 | crayons.blue(
205 | event.get('end', 'No End Time Given'), bold=True),
206 | crayons.white(event.get('type', 'Lesson Type'), bold=True),
207 | '{:<41}'.format(event.get('desc2', 'Description')[:41]),
208 | event.get('locAdd1', 'Location')
209 | ))
210 | else:
211 | print(' {} - {}\t\t\t\t{}\n ↳ {} {}\n'.format(
212 | crayons.magenta(
213 | event.get('start', 'No Start Time Given'), bold=True),
214 | crayons.magenta(
215 | event.get('end', 'No End Time Given'), bold=True),
216 | crayons.white(event.get('type', 'Lesson Type'), bold=True),
217 | '{:<41}'.format(event.get('desc2', 'Description')[:41]),
218 | event.get('locAdd1', 'Location')
219 | ))
220 |
221 | print("It is currently ", end="")
222 | print(
223 | crayons.magenta("{}".format(
224 | datetime.utcnow().strftime("%H:%M")), bold=True), end=""
225 | )
226 | print(" on ", end="")
227 | print(
228 | crayons.green("{}".format(
229 | datetime.utcnow().strftime("%a %d %b %Y")), bold=True), end=""
230 | )
231 | print('\n')
232 |
233 | sys.exit
234 |
235 | '''
236 |
237 | {} < /username >
238 | {} < /password >
239 | course_timetable < /calType >
240 | 2019-02-25T00: 00: 00.000+00: 00 < /start >
241 | 2019-03-04T00: 00: 00.000+00: 00 < /end >
242 |
243 | '''
244 |
--------------------------------------------------------------------------------
/kclical.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | '''
3 | Parse King's Calender to iCal format
4 | Version 2019-02-25.22
5 | '''
6 |
7 | import xml.etree.ElementTree as ET
8 | from datetime import datetime
9 | from datetime import timedelta
10 | from getpass import getpass
11 | import sys
12 | import os
13 | import re
14 | import keyring
15 | import requests
16 | from icalendar import Calendar, Event
17 | import argparse
18 |
19 | parser = argparse.ArgumentParser(
20 | description='Download your KCL timetable as a calendar on the command line.')
21 | parser.add_argument('-r', '--reset',
22 | action="store_true", dest="reset",
23 | help="Reset your login credentials", default=False)
24 | parser.add_argument('--reverse',
25 | action="store_true", dest="reverse",
26 | help="Print timetable dates in reverse order", default=False)
27 | parser.add_argument('-d', '--days', nargs='?', default='300', const='const',
28 | help="The number of days to look ahead")
29 |
30 | args = parser.parse_args()
31 |
32 | days = 300
33 | if args.days.isnumeric():
34 | days = int(args.days)
35 | else:
36 | exit("'days' parameter must be int")
37 |
38 | # keyring namespace for this app
39 | SERVICE_ID = 'kcl_timetable'
40 | KNUM_REG_PATTERN = re.compile("^[k|K]\d{6,10}$")
41 |
42 | if (args.reset):
43 | try:
44 | keyring.delete_password(SERVICE_ID, "knumber")
45 | except:
46 | print("No K-Number stored")
47 |
48 | try:
49 | keyring.delete_password(SERVICE_ID, "password")
50 | except:
51 | print("No password stored")
52 |
53 |
54 | knum = keyring.get_password(SERVICE_ID, "knumber")
55 |
56 | if knum == None:
57 | knum = ""
58 | while not re.match(KNUM_REG_PATTERN, knum):
59 | knum = input("Enter a valid K-Number: ")
60 | pwd = getpass("Enter your password: ")
61 | keyring.set_password(SERVICE_ID, "knumber", knum)
62 | keyring.set_password(SERVICE_ID, "password", pwd)
63 |
64 | password = keyring.get_password(SERVICE_ID, "password") # retrieve password
65 |
66 | UNAME = knum
67 | PASSWD = password
68 |
69 | STARTDATE = (datetime.utcnow().replace(
70 | hour=0, minute=0, second=0, microsecond=0)).isoformat()
71 | ENDDATE = (datetime.utcnow() + timedelta(days=240)).isoformat()
72 |
73 | XML_BODY = '''
74 | {}
75 | {}
76 | course_timetable
77 | {}
78 | {}
79 | '''.format(UNAME, PASSWD, STARTDATE, ENDDATE)
80 |
81 | APP_REQ_HEADERS = {
82 | 'Host': 'campusm.kcl.ac.uk',
83 | 'Content-Type': 'application/xml',
84 | 'Accept-Encoding': 'gzip',
85 | 'Pragma': 'no-cache',
86 | 'User-Agent': '''King's%20Mobile/9081458 CFNetwork/978.0.7 Darwin/18.7.0''',
87 | 'Content-Length': '291',
88 | 'Accept': '*/*',
89 | 'Accept-Language': 'en-gb',
90 | 'Authorization': '''Basic YXBwbGljYXRpb25fc2VjX3VzZXI6ZjJnaDUzNDg=''',
91 | 'Cache-Control': 'no-cache',
92 | 'Connection': 'keep-alive'
93 | }
94 |
95 | XML_RESPONSE = ""
96 | try:
97 | XML_RESPONSE = requests.post(
98 | 'https://campusm.kcl.ac.uk//kclNewTimetable/services/CampusMUniversityService/retrieveCalendar',
99 | data=XML_BODY, headers=APP_REQ_HEADERS, verify=True).text
100 | except:
101 | try:
102 | XML_RESPONSE = requests.post(
103 | 'https://campusm.kcl.ac.uk//kclNewTimetable/services/CampusMUniversityService/retrieveCalendar',
104 | data=XML_BODY, headers=APP_REQ_HEADERS, verify=False).text
105 | print("Was unable to verify SSL Cert. Disabled 'verify=True' flag for Request")
106 | except:
107 | print("Unable to connect - check internet connection and try again?")
108 | sys.exit(0)
109 |
110 | ROOT = ET.fromstring(XML_RESPONSE)
111 | CALITEMS = (list(list(ROOT)[0]))
112 |
113 | dates = {}
114 |
115 | for item in CALITEMS:
116 | calentry = {}
117 | for field in list(item):
118 | calentry[field.tag.replace(
119 | '{http://campusm.gw.com/campusm}', '')] = field.text
120 |
121 | date_time_str = calentry['end']
122 | date_time_obj = datetime.fromisoformat(date_time_str)
123 | calentry['end'] = date_time_obj.strftime('%H:%M')
124 |
125 | date_time_str = calentry['start']
126 | date_time_obj = datetime.fromisoformat(date_time_str)
127 | calentry['start'] = date_time_obj.strftime('%H:%M')
128 |
129 | date_key = date_time_obj.strftime("%Y-%m-%d")
130 |
131 | if('/Tut/' in calentry['desc1']):
132 | calentry['type'] = 'Large Group Tut.'
133 | elif('/Discussion/' in calentry['desc1']):
134 | calentry['type'] = 'Discussion'
135 | elif('/Prac/' in calentry['desc1']):
136 | calentry['type'] = 'Practical/Lab'
137 | elif('/Lecture' in calentry['desc1']):
138 | calentry['type'] = 'Lecture'
139 | elif('/SmG/' in calentry['desc1']):
140 | calentry['type'] = 'Small Group Tut.'
141 | elif('/DROP IN/' in calentry['desc1']):
142 | calentry['type'] = 'Drop-In Session'
143 | else:
144 | calentry['type'] = 'Lesson'
145 |
146 | if('/' in calentry['desc1']):
147 | m = re.search(r" \w*\/", calentry['desc1'])
148 | calentry['desc1'] = calentry['desc1'][:m.start()]
149 |
150 | if(calentry['desc2'] == None):
151 | calentry['desc2'] = calentry['desc1']
152 |
153 | dates.setdefault(date_key, []).append(calentry)
154 |
155 | if len(dates.keys()) < 1:
156 | print("\n[No Events Found]\n")
157 | else:
158 | print("\nExported timetable for the next {} days:".format(days))
159 |
160 |
161 | cal = Calendar()
162 |
163 | for key in sorted(dates.keys(), reverse=True): # Top to bottom flag
164 | dt = datetime.strptime(key, '%Y-%m-%d')
165 | events = dates[key]
166 |
167 | for row in events:
168 | event = Event()
169 |
170 | start = datetime.combine(
171 | dt, datetime.strptime(row.get('start', '08:00'), '%H:%M').time()
172 | )
173 | end = datetime.combine(
174 | dt, datetime.strptime(row.get('end', '20:00'), '%H:%M').time()
175 | )
176 |
177 | event.add('dtstart', start)
178 | event.add('dtend', end)
179 |
180 | event.add('description', row['desc1']+' - ' +
181 | row.get('teacherName', 'Teaching Assistant'))
182 | # event.add('summary', row['type']+' - '+row['desc2'])
183 | event.add('summary', row.get('desc2', 'Description'))
184 |
185 | # location data: locAdd1, locCode
186 | event.add('location', row.get(
187 | 'locAdd1', 'Online/Remote' if 'locCode' not in row else ''))
188 |
189 | uid = '{}{}{}{}'.format(start, end, event.get(
190 | 'location'), event.get('description'))
191 | uid = ''.join(c for c in uid if (c.isalnum()
192 | or c in '_-')) # strip to alphanum/_/-
193 | event.add('uid', uid)
194 |
195 | cal.add_component(event)
196 |
197 | desktop = os.path.expanduser("~/Desktop")
198 | timestamp = (datetime.now().strftime("%b%Y-%H_%M_%S")).lower()
199 | calendar_path = "" + desktop + "/course_schedule_" + timestamp + ".ics"
200 |
201 | f = open(calendar_path, 'wb')
202 | f.write(cal.to_ical())
203 | f.close()
204 |
205 | print("Exported to " + calendar_path)
206 |
207 | print("Opening in calendar...")
208 | os.system('open ' + calendar_path)
209 | print("Done! Goodbye :)")
210 |
211 | sys.exit
212 |
--------------------------------------------------------------------------------
/screenshots/calendar_export.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/finwarman/kcl-timetable/3b7aaabcf3b596b04430f7a97175aed8a4d3f165/screenshots/calendar_export.png
--------------------------------------------------------------------------------
/screenshots/timetable_fetch_kclpy.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/finwarman/kcl-timetable/3b7aaabcf3b596b04430f7a97175aed8a4d3f165/screenshots/timetable_fetch_kclpy.png
--------------------------------------------------------------------------------