forked from Ugric/cov-to-ics
change to use jinja2 and stop saving file and then reading the data
This commit is contained in:
64
main.py
64
main.py
@@ -3,22 +3,23 @@ from datetime import datetime, timezone
|
|||||||
import pytz
|
import pytz
|
||||||
import hashlib
|
import hashlib
|
||||||
import os
|
import os
|
||||||
from flask import Flask, request, send_file, render_template
|
from flask import Flask, request, send_file, render_template, Response
|
||||||
import requests
|
import requests
|
||||||
from requests_ntlm import HttpNtlmAuth
|
from requests_ntlm import HttpNtlmAuth
|
||||||
|
import urllib
|
||||||
|
|
||||||
# ------------------------------------------------------------
|
# ------------------------------------------------------------
|
||||||
# Config
|
# Config
|
||||||
# ------------------------------------------------------------
|
# ------------------------------------------------------------
|
||||||
|
|
||||||
RESPONSE_FILE = "response.txt"
|
|
||||||
OLD_CALENDAR = "old_calendar.ics"
|
|
||||||
NEW_CALENDAR = "calendar.ics"
|
|
||||||
LOCAL_TZ = pytz.timezone("Europe/London")
|
LOCAL_TZ = pytz.timezone("Europe/London")
|
||||||
URL = "https://webapp.coventry.ac.uk/Timetable-main/Timetable/Current"
|
URL = "https://webapp.coventry.ac.uk/Timetable-main/Timetable/Current"
|
||||||
|
|
||||||
|
print(URL.format(urllib.parse.quote_plus("hello/world")))
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
|
|
||||||
|
|
||||||
def parse_events(events_data):
|
def parse_events(events_data):
|
||||||
events_data = events_data.replace("new Date", "")
|
events_data = events_data.replace("new Date", "")
|
||||||
cleaned_data = ""
|
cleaned_data = ""
|
||||||
@@ -52,10 +53,7 @@ def parse_events(events_data):
|
|||||||
return parsed_data
|
return parsed_data
|
||||||
|
|
||||||
|
|
||||||
def get_events_data_from_file(path):
|
def get_events_data(page_data):
|
||||||
with open(path, "r", encoding="utf-8") as f:
|
|
||||||
page_data = f.read()
|
|
||||||
|
|
||||||
soup = BeautifulSoup(page_data, features="html.parser")
|
soup = BeautifulSoup(page_data, features="html.parser")
|
||||||
|
|
||||||
for script in soup.head.find_all("script", {"type": "text/javascript"}):
|
for script in soup.head.find_all("script", {"type": "text/javascript"}):
|
||||||
@@ -89,6 +87,7 @@ def create_ics_event(event):
|
|||||||
|
|
||||||
def load_existing_uids(path):
|
def load_existing_uids(path):
|
||||||
uids = set()
|
uids = set()
|
||||||
|
return uids
|
||||||
if not os.path.exists(path):
|
if not os.path.exists(path):
|
||||||
return uids
|
return uids
|
||||||
|
|
||||||
@@ -111,6 +110,7 @@ def load_existing_uids(path):
|
|||||||
|
|
||||||
return uids
|
return uids
|
||||||
|
|
||||||
|
|
||||||
def fetch_timetable(username, password):
|
def fetch_timetable(username, password):
|
||||||
session = requests.Session()
|
session = requests.Session()
|
||||||
session.auth = HttpNtlmAuth(username, password)
|
session.auth = HttpNtlmAuth(username, password)
|
||||||
@@ -118,16 +118,17 @@ def fetch_timetable(username, password):
|
|||||||
r = session.get(URL)
|
r = session.get(URL)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
|
|
||||||
with open("response.txt", "w", encoding="utf-8") as f:
|
session.close()
|
||||||
f.write(r.text)
|
return r.text
|
||||||
|
|
||||||
def build_calendar():
|
|
||||||
events_js = get_events_data_from_file(RESPONSE_FILE)
|
def build_calendar(page_data):
|
||||||
|
events_js = get_events_data(page_data)
|
||||||
parsed_events = parse_events(events_js)
|
parsed_events = parse_events(events_js)
|
||||||
|
|
||||||
new_events = [create_ics_event(e) for e in parsed_events if e]
|
new_events = [create_ics_event(e) for e in parsed_events if e]
|
||||||
|
|
||||||
old_uids = load_existing_uids(OLD_CALENDAR)
|
old_uids = set()
|
||||||
new_uids = {e["uid"] for e in new_events}
|
new_uids = {e["uid"] for e in new_events}
|
||||||
|
|
||||||
now = ics_time(datetime.now(timezone.utc))
|
now = ics_time(datetime.now(timezone.utc))
|
||||||
@@ -140,7 +141,8 @@ def build_calendar():
|
|||||||
]
|
]
|
||||||
|
|
||||||
for ev in new_events:
|
for ev in new_events:
|
||||||
lines.extend([
|
lines.extend(
|
||||||
|
[
|
||||||
"BEGIN:VEVENT",
|
"BEGIN:VEVENT",
|
||||||
f"UID:{ev['uid']}",
|
f"UID:{ev['uid']}",
|
||||||
f"DTSTAMP:{now}",
|
f"DTSTAMP:{now}",
|
||||||
@@ -149,45 +151,47 @@ def build_calendar():
|
|||||||
f"SUMMARY:{ev['summary']}",
|
f"SUMMARY:{ev['summary']}",
|
||||||
f"DESCRIPTION:{ev['description']}",
|
f"DESCRIPTION:{ev['description']}",
|
||||||
"END:VEVENT",
|
"END:VEVENT",
|
||||||
])
|
]
|
||||||
|
)
|
||||||
|
|
||||||
for uid in old_uids - new_uids:
|
for uid in old_uids - new_uids:
|
||||||
lines.extend([
|
lines.extend(
|
||||||
|
[
|
||||||
"BEGIN:VEVENT",
|
"BEGIN:VEVENT",
|
||||||
f"UID:{uid}",
|
f"UID:{uid}",
|
||||||
f"DTSTAMP:{now}",
|
f"DTSTAMP:{now}",
|
||||||
"STATUS:CANCELLED",
|
"STATUS:CANCELLED",
|
||||||
"END:VEVENT",
|
"END:VEVENT",
|
||||||
])
|
]
|
||||||
|
)
|
||||||
|
|
||||||
lines.append("END:VCALENDAR")
|
lines.append("END:VCALENDAR")
|
||||||
|
|
||||||
with open(NEW_CALENDAR, "w", encoding="utf-8") as f:
|
return "\n".join(lines)
|
||||||
f.write("\n".join(lines))
|
|
||||||
|
|
||||||
# ------------------------------------------------------------
|
# ------------------------------------------------------------
|
||||||
# Flask
|
# Flask
|
||||||
# ------------------------------------------------------------
|
# ------------------------------------------------------------
|
||||||
@app.route("/")
|
@app.route("/")
|
||||||
def index():
|
def index():
|
||||||
return render_template("index.html")
|
return render_template("login.jinja")
|
||||||
|
|
||||||
|
|
||||||
@app.route("/login-and-download", methods=["POST"])
|
@app.route("/login-and-download", methods=["POST"])
|
||||||
def login_and_download():
|
def login_and_download():
|
||||||
data = request.get_json()
|
data = request.form
|
||||||
username = data["username"]
|
username = data["username"]
|
||||||
password = data["password"]
|
password = data["password"]
|
||||||
username = f'COVENTRY\\{username}'
|
|
||||||
try:
|
try:
|
||||||
fetch_timetable(username, password) #request
|
data = build_calendar(fetch_timetable(f"COVENTRY\\{username}", password))
|
||||||
build_calendar()
|
return Response(
|
||||||
return send_file(
|
data,
|
||||||
NEW_CALENDAR,
|
mimetype="text/calendar",
|
||||||
as_attachment=True,
|
headers={"Content-Disposition": f'attachment; filename="{username}-timetable.ics"'},
|
||||||
download_name="timetable.ics",
|
|
||||||
mimetype="text/calendar"
|
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return str(e), 400
|
return render_template("login.jinja", error=str(e)), 401
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -1,5 +1,24 @@
|
|||||||
beautifulsoup4==4.14.3
|
beautifulsoup4==4.14.3
|
||||||
|
blinker==1.9.0
|
||||||
bs4==0.0.2
|
bs4==0.0.2
|
||||||
|
certifi==2026.1.4
|
||||||
|
cffi==2.0.0
|
||||||
|
charset-normalizer==3.4.4
|
||||||
|
click==8.3.1
|
||||||
|
cryptography==46.0.3
|
||||||
|
Flask==3.1.2
|
||||||
|
gunicorn==24.0.0
|
||||||
|
idna==3.11
|
||||||
|
itsdangerous==2.2.0
|
||||||
|
Jinja2==3.1.6
|
||||||
|
MarkupSafe==3.0.3
|
||||||
|
packaging==26.0
|
||||||
|
pycparser==3.0
|
||||||
|
pyspnego==0.12.0
|
||||||
pytz==2025.2
|
pytz==2025.2
|
||||||
|
requests==2.32.5
|
||||||
|
requests_ntlm==1.3.0
|
||||||
soupsieve==2.8.2
|
soupsieve==2.8.2
|
||||||
typing_extensions==4.15.0
|
typing_extensions==4.15.0
|
||||||
|
urllib3==2.6.3
|
||||||
|
Werkzeug==3.1.5
|
||||||
|
|||||||
94
static/styles.css
Normal file
94
static/styles.css
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
:root {
|
||||||
|
--text: #e3f2fc;
|
||||||
|
--background: #031621;
|
||||||
|
--primary: #a1c5da;
|
||||||
|
--secondary: #6a139b;
|
||||||
|
--accent: #52ace0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
color: var(--text);
|
||||||
|
margin: 0;
|
||||||
|
background: var(--background);
|
||||||
|
/* fallback dark color */
|
||||||
|
}
|
||||||
|
|
||||||
|
body::before {
|
||||||
|
content: "";
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 200vw;
|
||||||
|
height: 200vh;
|
||||||
|
background: radial-gradient(circle at 50% 0%, var(--accent) 0%, var(--background) 70%);
|
||||||
|
animation: moveLight 30s ease-in-out infinite alternate;
|
||||||
|
z-index: -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes moveLight {
|
||||||
|
0% {
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
transform: translate(-25%, -40%);
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
transform: translate(0%, -50%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-box {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0px;
|
||||||
|
width: fit-content;
|
||||||
|
height: fit-content;
|
||||||
|
max-width: 100vw;
|
||||||
|
max-height: 100dvh;
|
||||||
|
margin: auto;
|
||||||
|
background: var(--background);
|
||||||
|
padding: 30px;
|
||||||
|
border-radius: 8px;
|
||||||
|
text-align: center;
|
||||||
|
border: solid 1px var(--accent);
|
||||||
|
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
width: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px;
|
||||||
|
margin: 8px 0;
|
||||||
|
background-color: var(--background);
|
||||||
|
color: var(--text);
|
||||||
|
border: solid 1px var(--accent);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
margin-top: 1rem;
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px;
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--background);
|
||||||
|
font-size: large;
|
||||||
|
font-weight: bold;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover {
|
||||||
|
background: var(--primary);
|
||||||
|
color: var(--background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: red;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
21
templates/base.jinja
Normal file
21
templates/base.jinja
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<link rel="icon" type="image/png" sizes="96x96" href="{{ url_for('static', filename='favicon-96x96.png') }}">
|
||||||
|
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='favicon.svg') }}">
|
||||||
|
<link rel="shortcut icon" href="{{ url_for('static', filename='favicon.ico') }}">
|
||||||
|
<link rel="apple-touch-icon" sizes="180x180" href="{{ url_for('static', filename='apple-touch-icon.png') }}">
|
||||||
|
<meta name="apple-mobile-web-app-title" content="Timtabla">
|
||||||
|
<link rel="manifest" href="{{ url_for('static', filename='site.webmanifest') }}">
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Timtabla{% if page_title|trim %} - {{ page_title|trim }}{% endif %}</title>
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='styles.css') }}">
|
||||||
|
{% block head %}{% endblock %}
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
{% block body %}{% endblock %}
|
||||||
|
</html>
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<link rel="icon" type="image/png" sizes="96x96" href="{{ url_for('static', filename='favicon-96x96.png') }}">
|
|
||||||
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='favicon.svg') }}">
|
|
||||||
<link rel="shortcut icon" href="{{ url_for('static', filename='favicon.ico') }}">
|
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="{{ url_for('static', filename='apple-touch-icon.png') }}">
|
|
||||||
<meta name="apple-mobile-web-app-title" content="Timtabla">
|
|
||||||
<link rel="manifest" href="{{ url_for('static', filename='site.webmanifest') }}">
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<title>Timtabla</title>
|
|
||||||
<style>
|
|
||||||
body {
|
|
||||||
font-family: Arial, sans-serif;
|
|
||||||
margin: 0;
|
|
||||||
height: 100vh;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
background: linear-gradient(135deg, #03551B, #0b3c5d);
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-box {
|
|
||||||
background:white;
|
|
||||||
padding:30px;
|
|
||||||
border-radius:8px;
|
|
||||||
box-shadow:0 0 10px rgba(0,0,0,0.1);
|
|
||||||
width:300px;
|
|
||||||
}
|
|
||||||
input {
|
|
||||||
width:100%;
|
|
||||||
padding:8px;
|
|
||||||
margin:8px 0;
|
|
||||||
}
|
|
||||||
button {
|
|
||||||
width:100%;
|
|
||||||
padding:10px;
|
|
||||||
background:#0066cc;
|
|
||||||
color:white;
|
|
||||||
border:none;
|
|
||||||
cursor:pointer;
|
|
||||||
}
|
|
||||||
button:hover {
|
|
||||||
background:#004999;
|
|
||||||
}
|
|
||||||
.error {
|
|
||||||
color:red;
|
|
||||||
margin-top:10px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="login-box">
|
|
||||||
<h2>Login to Download</h2>
|
|
||||||
<input type="text" id="username" placeholder="Username" required>
|
|
||||||
<input type="password" id="password" placeholder="Password" required>
|
|
||||||
<button onclick="loginAndDownload()">Login & Download</button>
|
|
||||||
<div class="error" id="errorMsg"></div>
|
|
||||||
</div>
|
|
||||||
<script>
|
|
||||||
async function loginAndDownload() {
|
|
||||||
const username = document.getElementById("username").value;
|
|
||||||
const password = document.getElementById("password").value;
|
|
||||||
const errorMsg = document.getElementById("errorMsg");
|
|
||||||
errorMsg.textContent = "";
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch("/login-and-download", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ username, password })
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error("Invalid login or server error");
|
|
||||||
}
|
|
||||||
|
|
||||||
const blob = await response.blob();
|
|
||||||
const url = window.URL.createObjectURL(blob);
|
|
||||||
|
|
||||||
const a = document.createElement("a");
|
|
||||||
a.href = url;
|
|
||||||
a.download = "timetable.ics";
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
a.remove();
|
|
||||||
|
|
||||||
window.URL.revokeObjectURL(url);
|
|
||||||
|
|
||||||
} catch (err) {
|
|
||||||
errorMsg.textContent = err.message;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
16
templates/login.jinja
Normal file
16
templates/login.jinja
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{%set page_title = "Login"%}
|
||||||
|
{% extends "base.jinja" %}
|
||||||
|
{% block body %}
|
||||||
|
<form class="login-box" method="POST" action="/login-and-download">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<!-- Optional: server can re-render this with an error message -->
|
||||||
|
{% if error|trim %}<div class="error">{{error}}</div>{% endif %}
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user