diff options
| author | Arun Isaac | 2026-03-18 23:34:11 +0000 |
|---|---|---|
| committer | Arun Isaac | 2026-03-19 00:21:27 +0000 |
| commit | 78ea4a2c7efeeffdbc78cb22ef09998fb8b8ef08 (patch) | |
| tree | 86990f5c6d2adbdfdc7e537c978c747526275c8f | |
| download | meetfree-78ea4a2c7efeeffdbc78cb22ef09998fb8b8ef08.tar.gz meetfree-78ea4a2c7efeeffdbc78cb22ef09998fb8b8ef08.tar.lz meetfree-78ea4a2c7efeeffdbc78cb22ef09998fb8b8ef08.zip | |
Initial commit
| -rw-r--r-- | .gitignore | 1 | ||||
| -rw-r--r-- | README.md | 22 | ||||
| -rw-r--r-- | UNLICENSE | 24 | ||||
| -rw-r--r-- | meetfree.py | 234 | ||||
| -rw-r--r-- | pyproject.toml | 25 |
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 |
