From 78ea4a2c7efeeffdbc78cb22ef09998fb8b8ef08 Mon Sep 17 00:00:00 2001
From: Arun Isaac
Date: Wed, 18 Mar 2026 23:34:11 +0000
Subject: Initial commit
---
.gitignore | 1 +
README.md | 22 ++++++
UNLICENSE | 24 ++++++
meetfree.py | 234 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++
pyproject.toml | 25 ++++++
5 files changed, 306 insertions(+)
create mode 100644 .gitignore
create mode 100644 README.md
create mode 100644 UNLICENSE
create mode 100644 meetfree.py
create mode 100644 pyproject.toml
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..ed8ebf5
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+__pycache__
\ No newline at end of file
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..fdbea79
--- /dev/null
+++ b/README.md
@@ -0,0 +1,22 @@
+meetfree is a bottle web app that serves iCalendar and Atom feeds for meetup.com groups. meetfree fetches data from meetup.com using its GraphQL API. The GraphQL API does not require any authentication.
+
+# Configuration
+
+meetfree is configured using environment variables.
+
+- *MEETFREE_BASE_URL*: Base URL on which meetfree is served. For example, `"https://meetfree.systemreboot.net"`
+- *MEETFREE_ALLOWED_GROUPS*: Space-separated list of group slugs to serve feeds for. Group slugs are the first path component of the meetup.com group URL: for example, `london-emacs-hacking` in `https://www.meetup.com/london-emacs-hacking/`. If this environment variable is not set, all groups are served; there is no restriction.
+
+# Run development server
+
+Run the script directly to run the development server.
+```
+python3 meetfree.py
+```
+
+# Deployment
+
+Deploy using gunicorn (or any other WSGI server). For example:
+```
+gunicorn -w 4 meetfree:app
+```
diff --git a/UNLICENSE b/UNLICENSE
new file mode 100644
index 0000000..efb9808
--- /dev/null
+++ b/UNLICENSE
@@ -0,0 +1,24 @@
+This is free and unencumbered software released into the public domain.
+
+Anyone is free to copy, modify, publish, use, compile, sell, or
+distribute this software, either in source code form or as a compiled
+binary, for any purpose, commercial or non-commercial, and by any
+means.
+
+In jurisdictions that recognize copyright laws, the author or authors
+of this software dedicate any and all copyright interest in the
+software to the public domain. We make this dedication for the benefit
+of the public at large and to the detriment of our heirs and
+successors. We intend this dedication to be an overt act of
+relinquishment in perpetuity of all present and future rights to this
+software under copyright law.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
+OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
+ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+OTHER DEALINGS IN THE SOFTWARE.
+
+For more information, please refer to
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
+
+
+