from bs4 import BeautifulSoup from datetime import datetime, timezone import pytz import hashlib import os from flask import Flask, request, send_file, render_template, Response import requests from requests_ntlm import HttpNtlmAuth import urllib # ------------------------------------------------------------ # Config # ------------------------------------------------------------ LOCAL_TZ = pytz.timezone("Europe/London") URL = "https://webapp.coventry.ac.uk/Timetable-main/Timetable/Current" print(URL.format(urllib.parse.quote_plus("hello/world"))) app = Flask(__name__) def parse_events(events_data): events_data = events_data.replace("new Date", "") cleaned_data = "" for line in events_data.split("\n"): comment_pos = line.find("//") if comment_pos != -1: line = line[:comment_pos] if ":" in line: key, val = line.split(":", 1) line = f"'{key}': {val}" cleaned_data += line + "\n" parsed_data = eval(cleaned_data) for event in parsed_data: if "start" in event: s = list(event["start"]) s[1] += 1 s.append(0) event["start"] = LOCAL_TZ.localize(datetime(*s)) if "end" in event: e = list(event["end"]) e[1] += 1 e.append(0) event["end"] = LOCAL_TZ.localize(datetime(*e)) return parsed_data def get_events_data(page_data): soup = BeautifulSoup(page_data, features="html.parser") for script in soup.head.find_all("script", {"type": "text/javascript"}): if not script.has_attr("src"): source = script.text break else: raise RuntimeError("Could not find inline timetable script") return source.split("events:")[1].split("]")[0] + "]" def ics_time(dt): return dt.astimezone(timezone.utc).strftime("%Y%m%dT%H%M%SZ") def make_uid(event): key = f"{event['moduleDesc']}|{event['title']}|{event['start'].isoformat()}" return hashlib.sha1(key.encode()).hexdigest() + "@timetable" def create_ics_event(event): return { "uid": make_uid(event), "summary": f"{event['moduleDesc']} - {event['title']}", "description": f"{event['lecturer']} - {event['room']}", "start": event["start"], "end": event["end"], } def load_existing_uids(path): uids = set() return uids if not os.path.exists(path): return uids with open(path, "r", encoding="utf-8") as f: current_uid = None cancelled = False for line in f: line = line.strip() if line == "BEGIN:VEVENT": current_uid = None cancelled = False elif line.startswith("UID:"): current_uid = line[4:] elif line == "STATUS:CANCELLED": cancelled = True elif line == "END:VEVENT" and current_uid and not cancelled: uids.add(current_uid) return uids def fetch_timetable(username, password): session = requests.Session() session.auth = HttpNtlmAuth(username, password) r = session.get(URL) r.raise_for_status() session.close() return r.text def build_calendar(page_data): events_js = get_events_data(page_data) parsed_events = parse_events(events_js) new_events = [create_ics_event(e) for e in parsed_events if e] old_uids = set() new_uids = {e["uid"] for e in new_events} now = ics_time(datetime.now(timezone.utc)) lines = [ "BEGIN:VCALENDAR", "VERSION:2.0", "PRODID:-//Timetable Sync//EN", "CALSCALE:GREGORIAN", ] for ev in new_events: lines.extend( [ "BEGIN:VEVENT", f"UID:{ev['uid']}", f"DTSTAMP:{now}", f"DTSTART:{ics_time(ev['start'])}", f"DTEND:{ics_time(ev['end'])}", f"SUMMARY:{ev['summary']}", f"DESCRIPTION:{ev['description']}", "END:VEVENT", ] ) for uid in old_uids - new_uids: lines.extend( [ "BEGIN:VEVENT", f"UID:{uid}", f"DTSTAMP:{now}", "STATUS:CANCELLED", "END:VEVENT", ] ) lines.append("END:VCALENDAR") return "\n".join(lines) # ------------------------------------------------------------ # Flask # ------------------------------------------------------------ @app.route("/") def index(): return render_template("login.jinja") @app.route("/login-and-download", methods=["POST"]) def login_and_download(): data = request.form username = data["username"] password = data["password"] try: data = build_calendar(fetch_timetable(f"COVENTRY\\{username}", password)) return Response( data, mimetype="text/calendar", headers={"Content-Disposition": f'attachment; filename="{username}-timetable.ics"'}, ) except Exception as e: return render_template("login.jinja", error=str(e)), 401 if __name__ == "__main__": app.run(debug=True)