Created
January 25, 2026 12:02
-
-
Save VincenzoLaSpesa/82bcb0d19abf2745ae3e053a8e141e99 to your computer and use it in GitHub Desktop.
generate a VCARD calendar out of the events exported from https://fosdem.sojourner.rocks/2026/
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| # -*- coding: utf-8 -*- | |
| """ | |
| Filename: fosdem_events_to_vcards.py | |
| Author: Vincenzo La Spesa | |
| Date: 2026-01-25 | |
| Version: 1.0 | |
| Description: | |
| This script allows to make a VCARD calendar out of the events exported from https://fosdem.sojourner.rocks/2026/ | |
| as a CSV. | |
| The usage is: | |
| - Generate the full events list, with: fosdem_events_to_vcards.py --generatelookup -l lookup.pkl | |
| - Generate the ics file from the csv exported from solojourner and the lookup table generated before: | |
| fosdem_events_to_vcards.py -l lookup.pkl -i fosdem-2026.csv -o output.ics | |
| License: MIT License | |
| Contact: vincenzolaspesa@gmail.com | |
| Dependencies: bs4 (pip install beautifulsoup4) | |
| """ | |
| import pickle | |
| #import yaml | |
| import requests | |
| import datetime | |
| from bs4 import BeautifulSoup | |
| from dataclasses import dataclass, asdict | |
| from dateutil import parser | |
| import argparse | |
| import hashlib | |
| import csv | |
| URL = "https://fosdem.org/2026/schedule/events/" | |
| DAYS={ | |
| "Saturday" : parser.parse("2026-01-31 00:00"), | |
| "Sunday" : parser.parse("2026-02-01 00:00") | |
| } | |
| SERIALIZING_DATE=datetime.datetime.now() | |
| @dataclass | |
| class Event: | |
| title: str = None | |
| speakers: str = None | |
| track: str = None | |
| start: datetime.datetime = datetime.datetime.now() | |
| end: datetime.datetime = datetime.datetime.now() | |
| room: str = None | |
| url: str = None | |
| description: str = "" | |
| def stable_uid(self) -> str: | |
| m = hashlib.sha256() | |
| m.update(self.title.encode("utf-8")) | |
| m.update(self.speakers.encode("utf-8")) | |
| m.update(self.track.encode("utf-8")) | |
| m.update(datetime_to_str(self.start).encode("utf-8")) | |
| m.update(datetime_to_str(self.end).encode("utf-8")) | |
| m.update(self.url.encode("utf-8")) | |
| m.digest() | |
| digest=m.hexdigest() | |
| chunks=[] | |
| for i in range(0,int(0.25*len(digest))): | |
| if i>4: | |
| chunks.append(digest[i-4:i]) | |
| digest='-'.join(chunks) | |
| return digest[:29] | |
| def to_vcard(self): | |
| return f"""BEGIN:VEVENT | |
| UID:{self.stable_uid()} | |
| DTSTAMP:{datetime_to_str(SERIALIZING_DATE)} | |
| DTSTART:{datetime_to_str(self.start)} | |
| DTEND:{datetime_to_str(self.end)} | |
| SUMMARY:{self.title} ({self.track}) | |
| DESCRIPTION:{self.description}{self.url} {self.track} | |
| CLASS:PUBLIC | |
| URL:{self.url} | |
| LOCATION:{self.room} | |
| END:VEVENT""" | |
| def datetime_to_str(dt: datetime.datetime): | |
| dt_utc = dt.astimezone(datetime.timezone.utc) | |
| return dt_utc.strftime("%Y%m%dT%H%M%SZ") | |
| def parse_single_event(table_row, track, baseurl="https://fosdem.org") -> dict: | |
| columns=table_row.select("td") | |
| basetime= DAYS[columns[3].text] | |
| start_a=[int(x) for x in columns[4].text.split(":")] | |
| end_a=[int(x) for x in columns[5].text.split(":")] | |
| event= Event() | |
| event.track=track | |
| event.title=columns[0].text | |
| event.url=baseurl+columns[0].select("a")[0]['href'] | |
| event.speakers=columns[1].text | |
| event.room=columns[2].text | |
| event.start=basetime+datetime.timedelta(hours=start_a[0], minutes=start_a[1]) | |
| event.end=basetime+datetime.timedelta(hours=end_a[0], minutes=end_a[1]) | |
| return event | |
| def parse_events(): | |
| print("Downloading events page…") | |
| events = [] | |
| html = requests.get(URL).text | |
| zuppa = BeautifulSoup(html, "html.parser") | |
| tables=zuppa.select("html body.schedule-events div#main table.table.table-striped.table-bordered.table-condensed") | |
| track=None | |
| for table in tables: | |
| rows=table.select("tr") | |
| for row in rows: | |
| h4=row.select_one("h4") | |
| if h4: | |
| track=h4.text | |
| nlink=len(row.select("a")) | |
| ntd=len(row.select("td")) | |
| if(ntd>3 and nlink>2): | |
| events.append(parse_single_event(row,track)) | |
| print(events[-1].title) | |
| m=Event() | |
| m.title="metadata" | |
| m.start=SERIALIZING_DATE | |
| events.append(m) | |
| return events | |
| def convert_csv_to_ics(csv_path, ics_path, lookup): | |
| keys=[] | |
| with open(csv_path, newline='', encoding='utf-8') as f: | |
| rreader = csv.DictReader(f) | |
| for row in rreader: | |
| keys.append(row["Title"].lower()) | |
| events=[] | |
| for k in keys: | |
| events.append(lookup[k]) | |
| with open(ics_path, "w", encoding="utf-8") as f: | |
| f.write("BEGIN:VCALENDAR\n") | |
| f.write("VERSION:2.0\n") | |
| f.write("PRODID:-//be.digitalia.fosdem//NONSGML 2.3.0//EN\n") | |
| for event in events: | |
| f.write(event.to_vcard() + "\n") | |
| f.write("END:VCALENDAR\n") | |
| parser = argparse.ArgumentParser() | |
| parser.add_argument("-i", "--input",type=str, help="input csv file") | |
| parser.add_argument("-o", "--output",type=str, help="output ics file") | |
| parser.add_argument('-l',"--lookup", type=str, help="input lookup file") | |
| parser.add_argument('--generatelookup', action='store_true') | |
| args = parser.parse_args() | |
| if args.generatelookup: | |
| assert args.lookup != None, "The --generatelookup flag needs a --lookup file to be set" | |
| events = parse_events() | |
| events.sort(key=lambda x : x.title) | |
| #with open(f"{args.lookup}.yaml", 'w') as file: | |
| # yaml.dump(events, file) | |
| pickle.dump(events, open(args.lookup, 'wb')) | |
| else: | |
| assert args.input and args.output and args.lookup, "both --input and --output and --lookup need to be set" | |
| list_ = pickle.load(open(args.lookup, 'rb')) | |
| lookup={} | |
| for l in list_: | |
| if isinstance(l, Event): | |
| lookup[l.title.lower()]=l | |
| convert_csv_to_ics(args.input, args.output, lookup) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment