about summary refs log tree commit diff
path: root/meetfree.py
diff options
context:
space:
mode:
authorArun Isaac2026-03-18 23:34:11 +0000
committerArun Isaac2026-03-19 00:21:27 +0000
commit78ea4a2c7efeeffdbc78cb22ef09998fb8b8ef08 (patch)
tree86990f5c6d2adbdfdc7e537c978c747526275c8f /meetfree.py
downloadmeetfree-78ea4a2c7efeeffdbc78cb22ef09998fb8b8ef08.tar.gz
meetfree-78ea4a2c7efeeffdbc78cb22ef09998fb8b8ef08.tar.lz
meetfree-78ea4a2c7efeeffdbc78cb22ef09998fb8b8ef08.zip
Initial commit
Diffstat (limited to 'meetfree.py')
-rw-r--r--meetfree.py234
1 files changed, 234 insertions, 0 deletions
diff --git a/meetfree.py b/meetfree.py
new file mode 100644
index 0000000..f7b55cc
--- /dev/null
+++ b/meetfree.py
@@ -0,0 +1,234 @@
+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
+    <!DOCTYPE html>
+    <html>
+      <head>
+        <title>{{group_name}}</title>
+      </head>
+      <body>
+        <header>
+          <h1>{{group_name}}</h1>
+          {{!group_description}}
+        </header>
+        <main>
+          <h2>Upcoming Events</h2>
+          % if events:
+              <dl>
+          %   for event in events:
+                <dt><a href="{{event.url}}">{{event.title}}</a></dt>
+                <dd>
+                  <time datetime="{{datetime.isoformat(event.start_time)}}">
+                     {{human_date(event.start_time)}}
+                  </time>
+                </dd>
+          %   end
+              </dl>
+          % else:
+              <p>No upcoming events</p>
+          % end
+        </main>
+      </body>
+    </html>
+    """,
+    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("/<slug>.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("/<slug>.ics")
+def ics(slug):
+    response.content_type = "text/calendar"
+    return group2ical(meetup_group_or_404(slug)).to_ical()
+
+@route("/<slug>")
+def html(slug):
+    return group2html(meetup_group_or_404(slug))
+
+if __name__ == "__main__":
+    run(host="localhost", port=8080, debug=True)