199 lines
5.1 KiB
Python
199 lines
5.1 KiB
Python
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)
|