forked from Ugric/cov-to-ics
add live calendar subscription
This commit is contained in:
1 parent
95cf90faec
commit
ccb8906e54
7 files changed
+129
-18
No files matched your search
@@ -6,7 +6,7 @@ Migrate the coventry university student timetable to an ics file
|
||||
|
||||
1. run `pip install -r requirements.txt`
|
||||
|
||||
2. if `calender.ics` exists already, rename it to `old_calender.ics`. This will allow the new calender file to update events from the old calender, and remove deleted events.
|
||||
2. if `calendar.ics` exists already, rename it to `old_calendar.ics`. This will allow the new calendar file to update events from the old calendar, and remove deleted events.
|
||||
|
||||
3. log into the coventry university timetable website, go into inspect element, go to the network tab and copy the response from the first request (refresh if no requests are showing up)
|
||||
|
||||
@@ -16,6 +16,6 @@ Migrate the coventry university student timetable to an ics file
|
||||
|
||||

|
||||
|
||||
5. run `main.py`, and you'll hopefully see `calender.ics` get created in the root folder of the repo :)
|
||||
5. run `main.py`, and you'll hopefully see `calendar.ics` get created in the root folder of the repo :)
|
||||
|
||||
6. Upload that ics file to any calender application, I imagine basically all of them allow you to upload an ics file (google calender and thunderbird do).
|
||||
6. Upload that ics file to any calendar application, I imagine basically all of them allow you to upload an ics file (google calendar and thunderbird do).
|
||||
+34
-3
@@ -8,33 +8,49 @@ import requests
|
||||
from requests_ntlm import HttpNtlmAuth
|
||||
import urllib
|
||||
import html
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime
|
||||
import ast
|
||||
|
||||
LOCAL_TZ = pytz.timezone("Europe/London")
|
||||
URL = "https://webapp.coventry.ac.uk/Timetable-main/Timetable/Current"
|
||||
|
||||
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}"
|
||||
val = val.strip()
|
||||
|
||||
if val.startswith("new Date"):
|
||||
val = re.sub(r'new Date\(([^)]*)\)', r'[\1]', val)
|
||||
|
||||
line = f"'{key.strip()}': {val}"
|
||||
|
||||
cleaned_data += line + "\n"
|
||||
parsed_data = eval(cleaned_data)
|
||||
|
||||
# Convert Python-style literals instead of JSON
|
||||
parsed_data = ast.literal_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):
|
||||
@@ -101,6 +117,21 @@ def fetch_timetable(username, password):
|
||||
session.close()
|
||||
return r.text
|
||||
|
||||
def fetch_auth_status(username, password):
|
||||
session = requests.Session()
|
||||
session.auth = HttpNtlmAuth(username, password)
|
||||
session.verify = False
|
||||
|
||||
try:
|
||||
r = session.get(URL, stream=True)
|
||||
r.raise_for_status()
|
||||
return False
|
||||
finally:
|
||||
r.close()
|
||||
session.close()
|
||||
return 300 > r.status_code >=200
|
||||
|
||||
|
||||
def build_calendar(page_data):
|
||||
events_js = get_events_data(page_data)
|
||||
parsed_events = parse_events(events_js)
|
||||
|
||||
@@ -9,23 +9,81 @@ from requests_ntlm import HttpNtlmAuth
|
||||
import urllib
|
||||
import html
|
||||
from cov2ics import *
|
||||
import base64
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
from dotenv import load_dotenv
|
||||
import codecs
|
||||
import urllib.parse
|
||||
load_dotenv()
|
||||
|
||||
def get_auth():
|
||||
username = None
|
||||
auth_encrypted_encoded = request.cookies.get("auth")
|
||||
nonce_encoded = request.cookies.get("nonce")
|
||||
|
||||
if auth_encrypted_encoded and nonce_encoded:
|
||||
auth_encrypted = base64.b64decode(auth_encrypted_encoded)
|
||||
nonce = base64.b64decode(nonce_encoded)
|
||||
auth = json.loads(aes.decrypt(nonce, auth_encrypted, None).decode("utf-8"))
|
||||
return fetch_auth_status(f"COVENTRY\\{auth['u']}", auth['p']), auth['u'], auth["p"]
|
||||
|
||||
return False, None
|
||||
|
||||
def generate_auth(username, password):
|
||||
nonce = os.urandom(12)
|
||||
auth_encrypted = aes.encrypt(nonce, json.dumps({"u": username, "p": password}).encode(), None)
|
||||
return auth_encrypted, nonce
|
||||
|
||||
|
||||
|
||||
print(URL.format(urllib.parse.quote_plus("hello/world")))
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
# Generate a 256-bit (32-byte) key
|
||||
# key = AESGCM.generate_key(bit_length=256)
|
||||
|
||||
key = codecs.decode(os.getenv("AES_KEY"), 'hex')
|
||||
|
||||
# Create the AES-GCM cipher
|
||||
aes = AESGCM(key)
|
||||
|
||||
|
||||
@app.route("/")
|
||||
def index():
|
||||
return redirect(url_for("home"))
|
||||
|
||||
@app.route("/home")
|
||||
def home():
|
||||
status, username, password = get_auth()
|
||||
if status:
|
||||
return redirect("/dashboard")
|
||||
return render_template("home.jinja")
|
||||
|
||||
@app.route("/get-timetable")
|
||||
def retrieve():
|
||||
@app.route("/login", methods=["GET"])
|
||||
def login():
|
||||
status, username, password = get_auth()
|
||||
if status:
|
||||
return redirect("/dashboard")
|
||||
return render_template("login.jinja")
|
||||
|
||||
@app.route("/login", methods=["POST"])
|
||||
def login_post():
|
||||
username = request.form.get("username")
|
||||
password = request.form.get("password")
|
||||
auth, nonce = generate_auth(username, password)
|
||||
resp = redirect("/dashboard")
|
||||
resp.set_cookie("auth", base64.b64encode(auth).decode("utf-8"), max_age=10 * 365 * 24 * 60 * 60)
|
||||
resp.set_cookie("nonce", base64.b64encode(nonce).decode("utf-8"), max_age=10 * 365 * 24 * 60 * 60)
|
||||
return resp
|
||||
|
||||
@app.route("/dashboard")
|
||||
def dashboard():
|
||||
status, username, password = get_auth()
|
||||
if not status:
|
||||
return redirect("/login")
|
||||
auth, nonce = generate_auth(username, password)
|
||||
auth_encoded = base64.b64encode(auth).decode("utf-8")
|
||||
nonce_encoded = base64.b64encode(nonce).decode("utf-8")
|
||||
url = "/timtabla.ics?"+urllib.parse.urlencode({"a": auth_encoded, "n": nonce_encoded})
|
||||
return render_template("dashboard.jinja", username=username, url=url)
|
||||
|
||||
@app.route("/download-ics", methods=["POST"])
|
||||
def download_ics():
|
||||
data = request.form
|
||||
@@ -36,10 +94,25 @@ def download_ics():
|
||||
return Response(
|
||||
data,
|
||||
mimetype="text/calendar",
|
||||
headers={"Content-Disposition": f'attachment; filename="{username}_timetable_{start_month}-{start_year}_to_{end_month}-{end_year}.ics"'},
|
||||
headers={"Content-Disposition": f'attachment; filename="{username}_timtabla_{start_month}-{start_year}_to_{end_month}-{end_year}.ics"'},
|
||||
)
|
||||
except Exception as e:
|
||||
return render_template("login.jinja", error=str(e)), 401
|
||||
return "Calendar Not Found", 404
|
||||
|
||||
@app.route("/timtabla.ics", methods=["GET"])
|
||||
def live_ics():
|
||||
auth_encrypted = base64.b64decode(request.args["a"])
|
||||
nonce = base64.b64decode(request.args["n"])
|
||||
auth = json.loads(aes.decrypt(nonce, auth_encrypted, None).decode("utf-8"))
|
||||
try:
|
||||
data, start_year, start_month, end_year, end_month = build_calendar(fetch_timetable(f"COVENTRY\\{auth["u"]}", auth["p"]))
|
||||
return Response(
|
||||
data,
|
||||
mimetype="text/calendar",
|
||||
headers={"Content-Disposition": f'attachment; filename="{auth["u"]}_timtabla.ics"'},
|
||||
)
|
||||
except Exception as e:
|
||||
return "Calendar Not Found", 404
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
|
||||
<body>
|
||||
<div class="page-content">
|
||||
<a href="{{ url_for('home') }}" class="title">
|
||||
<a href="{{ url_for('index') }}" class="title">
|
||||
<p >Timtabla</p>
|
||||
</a>
|
||||
{% block body %}{% endblock %}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{%set page_title = "Dashboard"%}
|
||||
{% extends "base.jinja" %}
|
||||
{% block body %}
|
||||
<div>
|
||||
<p>{{url}}</p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -8,6 +8,6 @@
|
||||
<p> This website is based on open-source code developed by Coventry University
|
||||
students, you can find this code at <a href="https://git.wbell.dev/Ugric/cov-to-ics">git.wbell.dev</a>
|
||||
</p>
|
||||
<a href="{{ url_for('retrieve') }}" class="button"> Convert timetable </a>
|
||||
<a href="{{ url_for('login') }}" class="button"> Convert timetable </a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,14 +1,14 @@
|
||||
{%set page_title = "Login"%}
|
||||
{% extends "base.jinja" %}
|
||||
{% block body %}
|
||||
<form class="login-box" method="POST" action="{{url_for('download_ics')}}">
|
||||
<form class="login-box" method="POST" action="{{url_for('login_post')}}">
|
||||
<h2>Coventry University Login</h2>
|
||||
|
||||
<input type="text" name="username" placeholder="Username" required>
|
||||
|
||||
<input type="password" name="password" placeholder="Password" required>
|
||||
|
||||
<button type="submit">Login & Download</button>
|
||||
<button type="submit">Login</button>
|
||||
|
||||
{% if error|trim %}<div class="error">{{error}}</div>{% endif %}
|
||||
</form>
|
||||
|
||||
Reference in new issue
Block a user