from collections import namedtuple from datetime import datetime import logging import os import urllib.parse from bottle import abort, default_app, response, route, run, template from feedgen.feed import FeedGenerator import icalendar as ical from justhtml import JustHTML from markdown import markdown import requests Group = namedtuple("Group", "name url description events") Event = namedtuple("Event", "url title description start_time end_time venue") Venue = namedtuple("Venue", "name address city state country") class GroupNotFoundException(Exception): pass app = default_app() if os.getenv("MEETFREE_BASE_URL"): app.config["base_url"] = os.getenv("MEETFREE_BASE_URL") else: logging.warning("MEETFREE_BASE_URL environment variable unset; " "Atom feed will not have \"self\" URL.") if os.getenv("MEETFREE_ALLOWED_GROUPS"): app.config["allowed_groups"] = set( os.getenv("MEETFREE_ALLOWED_GROUPS").split()) def graphql_query(endpoint, query, **variables): response = requests.post(endpoint, json={"query": query, "variables": variables}, headers={"Content-Type": "application/json"}) response.raise_for_status() json_response = response.json() if ("errors" not in json_response) or (not "errors" in json_response): return json_response["data"] else: raise Exception(json_response["errors"]) def meetup_group(group_slug): def edge2event(edge): match edge: case { "node": { "eventUrl": url, "title": title, "description": description, "dateTime": start_time, "endTime": end_time, "venue": { "name": venue_name, "address": venue_address, "city": venue_city, "state": venue_state, "country": venue_country } } }: return Event(url, title, description, datetime.fromisoformat(start_time), datetime.fromisoformat(end_time), Venue(venue_name, venue_address, venue_city, venue_state, venue_country)) match graphql_query( "https://www.meetup.com/gql2", """ query ($urlname: String!) { groupByUrlname(urlname: $urlname) { name link description events(status: ACTIVE, first: 5) { edges { node { title description dateTime endTime eventUrl venue { name address city state country } } } } } } """, urlname=group_slug)["groupByUrlname"]: case { "name": name, "link": url, "description": description, "events": { "edges": edges } }: return Group(name, url, description, [edge2event(edge) for edge in edges]) case _: raise GroupNotFoundException(group_slug) def is_blank(string): return not string.strip() def venue2location(venue): return ", ".join( part for part in [venue.name, venue.address, venue.city, venue.state, venue.country] if not is_blank(part)) def human_date(dt): return dt.strftime("%A, %B %-d, %Y at %-I:%M %p") def group2atom(group, feed_self_url): feed = FeedGenerator() feed.id(group.url) feed.title(group.name) feed.link(href=group.url, rel="alternate") if feed_self_url: feed.link(href=feed_self_url, rel="self") for event in group.events: entry = feed.add_entry() entry.id(event.url) entry.title(event.title) entry.description(f""" Where: {venue2location(event.venue)} When: {human_date(event.start_time)} {event.description} """) entry.link(href=event.url) return feed def group2ical(group): return ical.Calendar.new( name=group.name, description=group.description, subcomponents=[ ical.Event.new( summary=event.title, description=event.description, start=event.start_time, end=event.end_time, location=venue2location(event.venue) ) for event in group.events ] ) def group2html(group): return template(""" % from datetime import datetime {{group_name}}

{{group_name}}

{{!group_description}}

Upcoming Events

% if events:
% for event in events:
{{event.title}}
% end
% else:

No upcoming events

% end
""", group_name = group.name, group_description = markdown( JustHTML(group.description, fragment=True).to_html()), events = group.events, human_date = human_date) def meetup_group_or_404(slug): try: if "allowed_groups" in app.config and slug not in app.config["allowed_groups"]: raise GroupNotFoundException(slug) group = meetup_group(slug) except GroupNotFoundException: abort(404, "Group not found") return group @route("/.atom") def atom(slug): response.content_type = "application/atom+xml" return (group2atom( meetup_group_or_404(slug), urllib.parse.urljoin(app.config["base_url"], f"/{slug}.atom") if "base_url" in app.config else None) .atom_str(pretty=True)) @route("/.ics") def ics(slug): response.content_type = "text/calendar" return group2ical(meetup_group_or_404(slug)).to_ical() @route("/") def html(slug): return group2html(meetup_group_or_404(slug)) if __name__ == "__main__": run(host="localhost", port=8080, debug=True)