change to create an ics file instead of using google calender

This commit is contained in:
Ugric
2026-01-20 02:34:25 +00:00
parent d81730d0b4
commit b40d0a5485
4 changed files with 127 additions and 423 deletions

6
.gitignore vendored
View File

@@ -1,6 +1,6 @@
credentials.json
token.json
timetableurl
response.txt
calendar.ics
old_calendar.ics
# Byte-compiled / optimized / DLL files
__pycache__/

18
Pipfile
View File

@@ -1,18 +0,0 @@
[[source]]
name = "pypi"
verify_ssl = true
url = "https://pypi.org/simple"
[packages]
google-api-python-client = "*"
"oauth2client" = "*"
requests = "*"
"bs4" = "*"
pyjsparser = "*"
pytz = "*"
[dev-packages]
pylint = "*"
[requires]
python_version = "3.6"

275
Pipfile.lock generated
View File

@@ -1,275 +0,0 @@
{
"_meta": {
"hash": {
"sha256": "ffdcc7d81db0f5c5be8a6c0e7f3d57a38eff7d66c904d23adfc53ba06e7af315"
},
"pipfile-spec": 6,
"requires": {
"python_version": "3.6"
},
"sources": [
{
"name": "pypi",
"url": "https://pypi.org/simple",
"verify_ssl": true
}
]
},
"default": {
"beautifulsoup4": {
"hashes": [
"sha256:194ec62a25438adcb3fdb06378b26559eda1ea8a747367d34c33cef9c7f48d57",
"sha256:90f8e61121d6ae58362ce3bed8cd997efb00c914eae0ff3d363c32f9a9822d10",
"sha256:f0abd31228055d698bb392a826528ea08ebb9959e6bea17c606fd9c9009db938"
],
"version": "==4.6.3"
},
"bs4": {
"hashes": [
"sha256:36ecea1fd7cc5c0c6e4a1ff075df26d50da647b75376626cc186e2212886dd3a"
],
"index": "pypi",
"version": "==0.0.1"
},
"cachetools": {
"hashes": [
"sha256:90f1d559512fc073483fe573ef5ceb39bf6ad3d39edc98dc55178a2b2b176fa3",
"sha256:d1c398969c478d336f767ba02040fa22617333293fb0b8968e79b16028dfee35"
],
"version": "==2.1.0"
},
"certifi": {
"hashes": [
"sha256:376690d6f16d32f9d1fe8932551d80b23e9d393a8578c5633a2ed39a64861638",
"sha256:456048c7e371c089d0a77a5212fb37a2c2dce1e24146e3b7e0261736aaeaa22a"
],
"version": "==2018.8.24"
},
"chardet": {
"hashes": [
"sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae",
"sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"
],
"version": "==3.0.4"
},
"google-api-python-client": {
"hashes": [
"sha256:5d5cb02c6f3112c68eed51b74891a49c0e35263380672d662f8bfe85b8114d7c",
"sha256:7cc47cf80b25ecd7f3d917ea247bb6c62587514e40604ae29c47c0e4ebd1174b"
],
"index": "pypi",
"version": "==1.7.4"
},
"google-auth": {
"hashes": [
"sha256:9ca363facbf2622d9ba828017536ccca2e0f58bd15e659b52f312172f8815530",
"sha256:a4cf9e803f2176b5de442763bd339b313d3f1ed3002e3e1eb6eec1d7c9bbc9b4"
],
"version": "==1.5.1"
},
"google-auth-httplib2": {
"hashes": [
"sha256:098fade613c25b4527b2c08fa42d11f3c2037dda8995d86de0745228e965d445",
"sha256:f1c437842155680cf9918df9bc51c1182fda41feef88c34004bd1978c8157e08"
],
"version": "==0.0.3"
},
"httplib2": {
"hashes": [
"sha256:e71daed9a0e6373642db61166fa70beecc9bf04383477f84671348c02a04cbdf"
],
"version": "==0.11.3"
},
"idna": {
"hashes": [
"sha256:156a6814fb5ac1fc6850fb002e0852d56c0c8d2531923a51032d1b70760e186e",
"sha256:684a38a6f903c1d71d6d5fac066b58d7768af4de2b832e426ec79c30daa94a16"
],
"version": "==2.7"
},
"oauth2client": {
"hashes": [
"sha256:b8a81cc5d60e2d364f0b1b98f958dbd472887acaf1a5b05e21c28c31a2d6d3ac",
"sha256:d486741e451287f69568a4d26d70d9acd73a2bbfa275746c535b4209891cccc6"
],
"index": "pypi",
"version": "==4.1.3"
},
"pyasn1": {
"hashes": [
"sha256:b9d3abc5031e61927c82d4d96c1cec1e55676c1a991623cfed28faea73cdd7ca",
"sha256:f58f2a3d12fd754aa123e9fa74fb7345333000a035f3921dbdaa08597aa53137"
],
"version": "==0.4.4"
},
"pyasn1-modules": {
"hashes": [
"sha256:a0cf3e1842e7c60fde97cb22d275eb6f9524f5c5250489e292529de841417547",
"sha256:a38a8811ea784c0136abfdba73963876328f66172db21a05a82f9515909bfb4e"
],
"version": "==0.2.2"
},
"pyjsparser": {
"hashes": [
"sha256:e4a659df3db42a2ff9fbc961eb6d4076a0b945e1aadfc20d48f913ad5dca011d"
],
"index": "pypi",
"version": "==2.5.2"
},
"pytz": {
"hashes": [
"sha256:a061aa0a9e06881eb8b3b2b43f05b9439d6583c206d0a6c340ff72a7b6669053",
"sha256:ffb9ef1de172603304d9d2819af6f5ece76f2e85ec10692a524dd876e72bf277"
],
"index": "pypi",
"version": "==2018.5"
},
"requests": {
"hashes": [
"sha256:63b52e3c866428a224f97cab011de738c36aec0185aa91cfacd418b5d58911d1",
"sha256:ec22d826a36ed72a7358ff3fe56cbd4ba69dd7a6718ffd450ff0e9df7a47ce6a"
],
"index": "pypi",
"version": "==2.19.1"
},
"rsa": {
"hashes": [
"sha256:14ba45700ff1ec9eeb206a2ce76b32814958a98e372006c8fb76ba820211be66",
"sha256:1a836406405730121ae9823e19c6e806c62bbad73f890574fff50efa4122c487"
],
"version": "==4.0"
},
"six": {
"hashes": [
"sha256:70e8a77beed4562e7f14fe23a786b54f6296e34344c23bc42f07b15018ff98e9",
"sha256:832dc0e10feb1aa2c68dcc57dbb658f1c7e65b9b61af69048abc87a2db00a0eb"
],
"version": "==1.11.0"
},
"uritemplate": {
"hashes": [
"sha256:01c69f4fe8ed503b2951bef85d996a9d22434d2431584b5b107b2981ff416fbd",
"sha256:1b9c467a940ce9fb9f50df819e8ddd14696f89b9a8cc87ac77952ba416e0a8fd",
"sha256:c02643cebe23fc8adb5e6becffe201185bf06c40bda5c0b4028a93f1527d011d"
],
"version": "==3.0.0"
},
"urllib3": {
"hashes": [
"sha256:a68ac5e15e76e7e5dd2b8f94007233e01effe3e50e8daddf69acfd81cb686baf",
"sha256:b5725a0bd4ba422ab0e66e89e030c806576753ea3ee08554382c14e685d117b5"
],
"markers": "python_version >= '2.6' and python_version != '3.2.*' and python_version != '3.1.*' and python_version != '3.0.*' and python_version != '3.3.*' and python_version < '4'",
"version": "==1.23"
}
},
"develop": {
"astroid": {
"hashes": [
"sha256:292fa429e69d60e4161e7612cb7cc8fa3609e2e309f80c224d93a76d5e7b58be",
"sha256:c7013d119ec95eb626f7a2011f0b63d0c9a095df9ad06d8507b37084eada1a8d"
],
"version": "==2.0.4"
},
"isort": {
"hashes": [
"sha256:1153601da39a25b14ddc54955dbbacbb6b2d19135386699e2ad58517953b34af",
"sha256:b9c40e9750f3d77e6e4d441d8b0266cf555e7cdabdcff33c4fd06366ca761ef8",
"sha256:ec9ef8f4a9bc6f71eec99e1806bfa2de401650d996c59330782b89a5555c1497"
],
"markers": "python_version != '3.3.*' and python_version >= '2.7' and python_version != '3.0.*' and python_version != '3.2.*' and python_version != '3.1.*'",
"version": "==4.3.4"
},
"lazy-object-proxy": {
"hashes": [
"sha256:0ce34342b419bd8f018e6666bfef729aec3edf62345a53b537a4dcc115746a33",
"sha256:1b668120716eb7ee21d8a38815e5eb3bb8211117d9a90b0f8e21722c0758cc39",
"sha256:209615b0fe4624d79e50220ce3310ca1a9445fd8e6d3572a896e7f9146bbf019",
"sha256:27bf62cb2b1a2068d443ff7097ee33393f8483b570b475db8ebf7e1cba64f088",
"sha256:27ea6fd1c02dcc78172a82fc37fcc0992a94e4cecf53cb6d73f11749825bd98b",
"sha256:2c1b21b44ac9beb0fc848d3993924147ba45c4ebc24be19825e57aabbe74a99e",
"sha256:2df72ab12046a3496a92476020a1a0abf78b2a7db9ff4dc2036b8dd980203ae6",
"sha256:320ffd3de9699d3892048baee45ebfbbf9388a7d65d832d7e580243ade426d2b",
"sha256:50e3b9a464d5d08cc5227413db0d1c4707b6172e4d4d915c1c70e4de0bbff1f5",
"sha256:5276db7ff62bb7b52f77f1f51ed58850e315154249aceb42e7f4c611f0f847ff",
"sha256:61a6cf00dcb1a7f0c773ed4acc509cb636af2d6337a08f362413c76b2b47a8dd",
"sha256:6ae6c4cb59f199d8827c5a07546b2ab7e85d262acaccaacd49b62f53f7c456f7",
"sha256:7661d401d60d8bf15bb5da39e4dd72f5d764c5aff5a86ef52a042506e3e970ff",
"sha256:7bd527f36a605c914efca5d3d014170b2cb184723e423d26b1fb2fd9108e264d",
"sha256:7cb54db3535c8686ea12e9535eb087d32421184eacc6939ef15ef50f83a5e7e2",
"sha256:7f3a2d740291f7f2c111d86a1c4851b70fb000a6c8883a59660d95ad57b9df35",
"sha256:81304b7d8e9c824d058087dcb89144842c8e0dea6d281c031f59f0acf66963d4",
"sha256:933947e8b4fbe617a51528b09851685138b49d511af0b6c0da2539115d6d4514",
"sha256:94223d7f060301b3a8c09c9b3bc3294b56b2188e7d8179c762a1cda72c979252",
"sha256:ab3ca49afcb47058393b0122428358d2fbe0408cf99f1b58b295cfeb4ed39109",
"sha256:bd6292f565ca46dee4e737ebcc20742e3b5be2b01556dafe169f6c65d088875f",
"sha256:cb924aa3e4a3fb644d0c463cad5bc2572649a6a3f68a7f8e4fbe44aaa6d77e4c",
"sha256:d0fc7a286feac9077ec52a927fc9fe8fe2fabab95426722be4c953c9a8bede92",
"sha256:ddc34786490a6e4ec0a855d401034cbd1242ef186c20d79d2166d6a4bd449577",
"sha256:e34b155e36fa9da7e1b7c738ed7767fc9491a62ec6af70fe9da4a057759edc2d",
"sha256:e5b9e8f6bda48460b7b143c3821b21b452cb3a835e6bbd5dd33aa0c8d3f5137d",
"sha256:e81ebf6c5ee9684be8f2c87563880f93eedd56dd2b6146d8a725b50b7e5adb0f",
"sha256:eb91be369f945f10d3a49f5f9be8b3d0b93a4c2be8f8a5b83b0571b8123e0a7a",
"sha256:f460d1ceb0e4a5dcb2a652db0904224f367c9b3c1470d5a7683c0480e582468b"
],
"version": "==1.3.1"
},
"mccabe": {
"hashes": [
"sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42",
"sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"
],
"version": "==0.6.1"
},
"pylint": {
"hashes": [
"sha256:1d6d3622c94b4887115fe5204982eee66fdd8a951cf98635ee5caee6ec98c3ec",
"sha256:31142f764d2a7cd41df5196f9933b12b7ee55e73ef12204b648ad7e556c119fb"
],
"index": "pypi",
"version": "==2.1.1"
},
"six": {
"hashes": [
"sha256:70e8a77beed4562e7f14fe23a786b54f6296e34344c23bc42f07b15018ff98e9",
"sha256:832dc0e10feb1aa2c68dcc57dbb658f1c7e65b9b61af69048abc87a2db00a0eb"
],
"version": "==1.11.0"
},
"typed-ast": {
"hashes": [
"sha256:0948004fa228ae071054f5208840a1e88747a357ec1101c17217bfe99b299d58",
"sha256:10703d3cec8dcd9eef5a630a04056bbc898abc19bac5691612acba7d1325b66d",
"sha256:1f6c4bd0bdc0f14246fd41262df7dfc018d65bb05f6e16390b7ea26ca454a291",
"sha256:25d8feefe27eb0303b73545416b13d108c6067b846b543738a25ff304824ed9a",
"sha256:29464a177d56e4e055b5f7b629935af7f49c196be47528cc94e0a7bf83fbc2b9",
"sha256:2e214b72168ea0275efd6c884b114ab42e316de3ffa125b267e732ed2abda892",
"sha256:3e0d5e48e3a23e9a4d1a9f698e32a542a4a288c871d33ed8df1b092a40f3a0f9",
"sha256:519425deca5c2b2bdac49f77b2c5625781abbaf9a809d727d3a5596b30bb4ded",
"sha256:57fe287f0cdd9ceaf69e7b71a2e94a24b5d268b35df251a88fef5cc241bf73aa",
"sha256:668d0cec391d9aed1c6a388b0d5b97cd22e6073eaa5fbaa6d2946603b4871efe",
"sha256:68ba70684990f59497680ff90d18e756a47bf4863c604098f10de9716b2c0bdd",
"sha256:6de012d2b166fe7a4cdf505eee3aaa12192f7ba365beeefaca4ec10e31241a85",
"sha256:79b91ebe5a28d349b6d0d323023350133e927b4de5b651a8aa2db69c761420c6",
"sha256:8550177fa5d4c1f09b5e5f524411c44633c80ec69b24e0e98906dd761941ca46",
"sha256:898f818399cafcdb93cbbe15fc83a33d05f18e29fb498ddc09b0214cdfc7cd51",
"sha256:94b091dc0f19291adcb279a108f5d38de2430411068b219f41b343c03b28fb1f",
"sha256:a26863198902cda15ab4503991e8cf1ca874219e0118cbf07c126bce7c4db129",
"sha256:a8034021801bc0440f2e027c354b4eafd95891b573e12ff0418dec385c76785c",
"sha256:bc978ac17468fe868ee589c795d06777f75496b1ed576d308002c8a5756fb9ea",
"sha256:c05b41bc1deade9f90ddc5d988fe506208019ebba9f2578c622516fd201f5863",
"sha256:c9b060bd1e5a26ab6e8267fd46fc9e02b54eb15fffb16d112d4c7b1c12987559",
"sha256:edb04bdd45bfd76c8292c4d9654568efaedf76fe78eb246dde69bdb13b2dad87",
"sha256:f19f2a4f547505fe9072e15f6f4ae714af51b5a681a97f187971f50c283193b6"
],
"markers": "python_version < '3.7' and implementation_name == 'cpython'",
"version": "==1.1.0"
},
"wrapt": {
"hashes": [
"sha256:d4d560d479f2c21e1b5443bbd15fe7ec4b37fe7e53d335d3b9b0a7b1226fe3c6"
],
"version": "==1.10.11"
}
}
}

251
main.py
View File

@@ -1,188 +1,185 @@
import requests
from bs4 import BeautifulSoup
from datetime import datetime, timezone
from googleapiclient.discovery import build
from httplib2 import Http
from oauth2client import file, client, tools
import pytz
import hashlib
import os
# We don't store this url in the source, as it is sensitive
URL = open("timetableurl").read().strip()
# ------------------------------------------------------------
# Config
# ------------------------------------------------------------
RESPONSE_FILE = "response.txt"
OLD_CALENDAR = "old_calendar.ics"
NEW_CALENDAR = "calendar.ics"
LOCAL_TZ = pytz.timezone("Europe/London")
# ------------------------------------------------------------
# Parsing timetable JS
# ------------------------------------------------------------
def parse_events(events_data):
# Replace date objects with tuples, easier to parse
# Replace JS date objects
events_data = events_data.replace("new Date", "")
cleaned_data = ""
# Remove comments, properties to keys
for line in events_data.split("\n"):
comment_pos = line.find("//")
if comment_pos != -1:
line = line[:comment_pos]
if ":" in line:
line_values = line.split(":")
line = "'" + line_values[0] + "': " + line_values[1]
cleaned_data += line + "\n"
key, val = line.split(":", 1)
line = f"'{key}': {val}"
cleaned_data += line + "\n"
# Parse the event, as if it were a dict
parsed_data = eval(cleaned_data)
# Parse the datetime info
for event in parsed_data:
if "start" in event:
event["start"] = list(event["start"])
event["start"][1] += 1
event["start"].append(0)
event["start"] = datetime(*event["start"])
event["start"] = pytz.timezone("Europe/London").localize(event["start"])
s = list(event["start"])
s[1] += 1
s.append(0)
event["start"] = LOCAL_TZ.localize(datetime(*s))
if "end" in event:
event["end"] = list(event["end"])
event["end"][1] += 1
event["end"].append(0)
event["end"] = datetime(*event["end"])
event["end"] = pytz.timezone("Europe/London").localize(event["end"])
e = list(event["end"])
e[1] += 1
e.append(0)
event["end"] = LOCAL_TZ.localize(datetime(*e))
return parsed_data
def get_events_data(url):
page_data = requests.get(url).text
def get_events_data_from_file(path):
with open(path, "r", encoding="utf-8") as f:
page_data = f.read()
soup = BeautifulSoup(page_data, features="html.parser")
source = ""
for script in soup.head.findAll("script", {"type": "text/javascript"}):
for script in soup.head.find_all("script", {"type": "text/javascript"}):
if not script.has_attr("src"):
source = script.text
break
events_data = source.split("events:")[1].split("]")[0] +"]"
else:
raise RuntimeError("Could not find inline timetable script")
return events_data
return source.split("events:")[1].split("]")[0] + "]"
# ------------------------------------------------------------
# ICS helpers
# ------------------------------------------------------------
def ics_time(dt):
return dt.astimezone(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
def create_google_event(event):
new_event = event.copy()
if not new_event:
return new_event
# Make the event the correct format
new_event["summary"] = event["moduleDesc"] + " - " + event["title"]
new_event["description"] = event["lecturer"] + " - " + event["room"]
new_event["end"] = {"dateTime": str(event["end"].isoformat()), "timeZone": "Europe/London"}
new_event["start"] = {"dateTime": str(event["start"].isoformat()), "timeZone": "Europe/London"}
new_event["reminders"] = {'useDefault': False,
'overrides': [{'method': 'popup', 'minutes': 30}]}
return new_event
def make_uid(event):
key = f"{event['moduleDesc']}|{event['title']}|{event['start'].isoformat()}"
return hashlib.sha1(key.encode()).hexdigest() + "@timetable"
# Read and write access
SCOPES = "https://www.googleapis.com/auth/calendar"
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 get_calendar_service():
'''
Connect to the google calendar service, and return the
service object
'''
# ------------------------------------------------------------
# Load existing calendar (UIDs only)
# ------------------------------------------------------------
store = file.Storage("token.json")
creds = store.get()
def load_existing_uids(path):
uids = set()
# Run prompt to get the google credentials
if not creds or creds.invalid:
flow = client.flow_from_clientsecrets("redentials.json", SCOPES)
creds = tools.run_flow(flow, store)
if not os.path.exists(path):
return uids
return build("calendar", "v3", http=creds.authorize(Http()))
with open(path, "r", encoding="utf-8") as f:
current_uid = None
cancelled = False
for line in f:
line = line.strip()
def execute_batch(service, commands):
batch = service.new_batch_http_request()
batch_count = 0
if line == "BEGIN:VEVENT":
current_uid = None
cancelled = False
for command in commands:
batch.add(command)
batch_count += 1
if batch_count > 999:
batch.execute()
elif line.startswith("UID:"):
current_uid = line[4:]
batch = service.new_batch_http_request()
batch_count = 0
if batch_count > 0:
batch.execute()
elif line == "STATUS:CANCELLED":
cancelled = True
elif line == "END:VEVENT" and current_uid and not cancelled:
uids.add(current_uid)
return uids
# ------------------------------------------------------------
# Main
# ------------------------------------------------------------
def main():
type_to_color = {}
# A queue of colors, where a color is removed when
# when an event we have not seen before exists
color_queue = list(range(0, 12, 3))
events_js = get_events_data_from_file(RESPONSE_FILE)
parsed_events = parse_events(events_js)
service = get_calendar_service()
new_events = [
create_ics_event(e)
for e in parsed_events
if e
]
# Get a list of all events in the future
results = service.events().list(timeMin=datetime.now().isoformat() + 'Z', calendarId='primary').execute()
future_events = results.get("items", [])
old_uids = load_existing_uids(OLD_CALENDAR)
new_uids = {e["uid"] for e in new_events}
cov_events = parse_events(get_events_data(URL))
new_events = []
now = ics_time(datetime.now(timezone.utc))
new_summaries = set()
lines = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//Timetable Sync//EN",
"CALSCALE:GREGORIAN",
]
for event in cov_events:
if not event:
continue
# Add / update events
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",
])
new_event = create_google_event(event)
# Cancel removed events
for uid in old_uids - new_uids:
lines.extend([
"BEGIN:VEVENT",
f"UID:{uid}",
f"DTSTAMP:{now}",
"STATUS:CANCELLED",
"END:VEVENT",
])
color_type = new_event["mainColor"]
if color_type in type_to_color:
colorId = type_to_color[color_type]
else:
colorId = color_queue.pop(0)
color_queue.append(colorId)
type_to_color[color_type] = colorId
lines.append("END:VCALENDAR")
new_event["colorId"] = colorId
with open(NEW_CALENDAR, "w", encoding="utf-8") as f:
f.write("\n".join(lines))
new_events.append(new_event)
new_summaries.add(new_event["summary"])
# Make sure we remove old events so as not to create duplicates
if not future_events:
print('No existing events found')
else:
deletes = []
for existing_event in future_events:
if "summary" in existing_event and existing_event["summary"] in new_summaries:
deletes.append(service.events()
.delete(calendarId='primary',
eventId=existing_event['id']))
print(f'Removing {len(deletes)} existing events')
execute_batch(service, deletes)
inserts = []
for new_event in new_events:
inserts.append(service.events()
.insert(body=new_event,
calendarId='primary'))
print(f"Inserting {len(inserts)} new events")
execute_batch(service, inserts)
print(f"Added / updated: {len(new_events)}")
print(f"Removed: {len(old_uids - new_uids)}")
print(f"Wrote {NEW_CALENDAR}")
# ------------------------------------------------------------
if __name__ == "__main__":
main()