├── .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 | ![kcl.py timetable fetch](https://raw.githubusercontent.com/finwarman/kcl-timetable/master/screenshots/timetable_fetch_kclpy.png "kcl.py Timetable View") 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 | ![kclical.py calendar export](https://raw.githubusercontent.com/finwarman/kcl-timetable/master/screenshots/calendar_export.png "kclical.py Calendar Export") 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 --------------------------------------------------------------------------------