about summary refs log tree commit diff
diff options
context:
space:
mode:
authorArun Isaac2026-03-18 23:34:11 +0000
committerArun Isaac2026-03-19 00:21:27 +0000
commit78ea4a2c7efeeffdbc78cb22ef09998fb8b8ef08 (patch)
tree86990f5c6d2adbdfdc7e537c978c747526275c8f
downloadmeetfree-78ea4a2c7efeeffdbc78cb22ef09998fb8b8ef08.tar.gz
meetfree-78ea4a2c7efeeffdbc78cb22ef09998fb8b8ef08.tar.lz
meetfree-78ea4a2c7efeeffdbc78cb22ef09998fb8b8ef08.zip
Initial commit
-rw-r--r--.gitignore1
-rw-r--r--README.md22
-rw-r--r--UNLICENSE24
-rw-r--r--meetfree.py234
-rw-r--r--pyproject.toml25
5 files changed, 306 insertions, 0 deletions
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 <https://unlicense.org/>
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)
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..8669445
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,25 @@
+[build-system]
+requires = ["flit_core >=3.11,<4"]
+build-backend = "flit_core.buildapi"
+
+[project]
+name = "meetfree"
+version = "0.1.0"
+authors = [
+  { name="Arun Isaac", email="arunisaac@systemreboot.net" }
+]
+description = "iCalendar and Atom feeds for meetup.com"
+readme = "README.md"
+classifiers = [
+  "Programming Language :: Python :: 3",
+  "Operating System :: OS Independent"
+]
+license = {file = "UNLICENSE"}
+dependencies = [
+  "bottle",
+  "feedgen",
+  "icalendar",
+  "justhtml",
+  "markdown",
+  "requests"
+]
\ No newline at end of file