Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# MeetupScraper

A lightweight Python library for scraping upcoming events from [meetup.com](https://meetup.com) groups.

## Features

- Fetch upcoming events for any public Meetup group
- Filter events by name using regular expressions
- Structured event data with title, date, URL, and venue information
- Venue details fetched via Meetup's GraphQL API
- Search events across all groups in a specific city
- Automatic space-to-hyphen conversion for meetup group names
- Handles both datetime and date-only event start times

## Installation

```bash
pip install -e .
```

Or install dependencies manually:

```bash
pip install requests lxml python-dateutil icalendar gql aiohttp
```

## Running the Script

Run the [`search_events.py`](search_events.py) script directly with the desired arguments:

```bash
# Search by meetup group
python search_events.py --meetup Hakierspejs-Łodź --regex Radio

# Search by city
python search_events.py --city Warsaw

# Search by city with regex filter and more groups
python search_events.py --city "Krakow" --regex Python --max-groups 20
```

### CLI Arguments

| Flag | Description | Required |
|------|-------------|----------|
| `--meetup`, `-m` | Meetup group name or URL slug (e.g., `Hakierspejs-Łodź`) | One of `--meetup` or `--city` |
| `--city`, `-c` | City name to search for events (e.g., `Warsaw`, `Krakow`) | One of `--meetup` or `--city` |
| `--regex`, `-r` | Filter events by title using a regular expression | No |
| `--max-groups` | Maximum number of groups to search when using `--city` (default: 10) | No |
| `--debug` | Enable debug logging | No |

## Dependencies

- `requests` — HTTP requests for iCal feed
- `icalendar` — iCal parsing
- `gql` + `aiohttp` — GraphQL client for venue data
- `lxml` — XML/HTML parsing (transitive dependency)
- `python-dateutil` — Date parsing (transitive dependency)

## License

WTFPL
61 changes: 59 additions & 2 deletions gql_data.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
from gql import Client, gql
from gql.transport.aiohttp import AIOHTTPTransport
import json
import requests


def get_gql_data(event_id):
"""Fetch event details from Meetup's GraphQL API."""
transport = AIOHTTPTransport(url="https://www.meetup.com/gql2")

client = Client(transport=transport)
Expand All @@ -29,8 +32,62 @@ def get_gql_data(event_id):
}
""")


result = client.execute(query, variable_values={"eventId": str(event_id)})

return(result)
return result


def geocode_city(city_name):
"""Convert city name to latitude/longitude using Nominatim."""
url = "https://nominatim.openstreetmap.org/search"
params = {
"q": city_name,
"format": "json",
"limit": 1,
}
headers = {
"User-Agent": "meetupscraper/1.0",
}
response = requests.get(url, params=params, headers=headers)
data = response.json()

if not data:
raise ValueError(f"Could not geocode city: {city_name}")

return float(data[0]["lat"]), float(data[0]["lon"])


def get_groups_by_city(city_name, limit=20):
"""Search for meetup groups in a given city using Meetup's GraphQL API."""
lat, lon = geocode_city(city_name)

transport = AIOHTTPTransport(url="https://www.meetup.com/gql2")

client = Client(transport=transport)

query = gql("""
query SearchGroups($query: String!, $first: Int!, $lat: Float!, $lon: Float!) {
groupSearch(first: $first, filter: {query: $query, lat: $lat, lon: $lon}) {
edges {
node {
id
name
urlname
description
city
country
}
}
totalCount
}
}
""")

result = client.execute(query, variable_values={
"query": city_name,
"first": limit,
"lat": lat,
"lon": lon,
})

return result
30 changes: 30 additions & 0 deletions meetupscraper.egg-info/PKG-INFO
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
Metadata-Version: 2.4
Name: meetupscraper
Version: 0.11.2
Summary: A web scraper for meetup.com
Home-page: http://github.com/hakierspejs/meetupscraper
Author: Hakiespejs Łódź
Author-email: d33tah@gmail.com
License: WTFPL
Requires-Python: >=3.7
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: requests
Requires-Dist: lxml
Requires-Dist: python-dateutil
Requires-Dist: icalendar
Requires-Dist: gql
Requires-Dist: aiohttp
Requires-Dist: json
Dynamic: author
Dynamic: author-email
Dynamic: description
Dynamic: description-content-type
Dynamic: home-page
Dynamic: license
Dynamic: license-file
Dynamic: requires-dist
Dynamic: requires-python
Dynamic: summary

Allows you to access events tied to a specific meetup group in a structured way
10 changes: 10 additions & 0 deletions meetupscraper.egg-info/SOURCES.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
LICENSE
README.md
setup.py
meetupscraper/__init__.py
meetupscraper.egg-info/PKG-INFO
meetupscraper.egg-info/SOURCES.txt
meetupscraper.egg-info/dependency_links.txt
meetupscraper.egg-info/entry_points.txt
meetupscraper.egg-info/requires.txt
meetupscraper.egg-info/top_level.txt
1 change: 1 addition & 0 deletions meetupscraper.egg-info/dependency_links.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@

2 changes: 2 additions & 0 deletions meetupscraper.egg-info/entry_points.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[console_scripts]
meetupscraper = search_events:main
7 changes: 7 additions & 0 deletions meetupscraper.egg-info/requires.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
requests
lxml
python-dateutil
icalendar
gql
aiohttp
json
1 change: 1 addition & 0 deletions meetupscraper.egg-info/top_level.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
meetupscraper
63 changes: 48 additions & 15 deletions meetupscraper/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from datetime import timezone
import icalendar
import requests
from gql_data import get_gql_data
from gql_data import get_gql_data, get_groups_by_city
import json


Expand Down Expand Up @@ -62,23 +62,9 @@ def get_upcoming_events(meetup_name, name_regex=None):
LOGGER.info("Skipping event %r", event_name)
continue

#location = str(component.get('location', ''))
#venue_name = location
#venue_street = ''
#
#if ',' in location:
# venue_parts = location.split(',', 1)
# venue_name = venue_parts[0].strip()
# venue_street = venue_parts[1].strip()
#
#venue = Venue(name=venue_name, street=venue_street)
## set to None if no address nor name
#if not venue_name and not venue_street:
# venue = None
try:
event_id = re.search(r".+\/(\d+)\/", event_url).groups()[0]
gql_data = get_gql_data(event_id)
print(gql_data['event']['venue'])
venue = Venue(
name=gql_data['event']['venue']['name'],
street=gql_data['event']['venue']['address'] + ', ' + gql_data['event']['venue']['city']
Expand All @@ -92,3 +78,50 @@ def get_upcoming_events(meetup_name, name_regex=None):
url=event_url,
venue=venue,
)


def get_events_by_city(city_name, name_regex=None, max_groups=10):
"""
Search for upcoming events in a given city by finding groups and aggregating their events.

Args:
city_name: Name of the city to search for events (e.g., 'Warsaw', 'Krakow')
name_regex: Optional regex pattern to filter events by title
max_groups: Maximum number of groups to search (default 10)

Yields:
Event objects for all upcoming events in the city
"""
LOGGER.info("Searching for groups in city: %s", city_name)

# Search for groups in the city
groups_result = get_groups_by_city(city_name, limit=max_groups)

groups = groups_result.get('groupSearch', {}).get('edges', [])
if not groups:
LOGGER.warning("No groups found for city: %s", city_name)
return

LOGGER.info("Found %d groups in %s", len(groups), city_name)

# Collect events from all groups
seen_events = set() # To avoid duplicates

for group_edge in groups:
group = group_edge.get('node', {})
urlname = group.get('urlname')

if not urlname:
continue

LOGGER.info("Fetching events from group: %s", group.get('name', urlname))

try:
for event in get_upcoming_events(urlname, name_regex=name_regex):
# Use URL as unique identifier to avoid duplicates
if event.url not in seen_events:
seen_events.add(event.url)
yield event
except Exception as e:
LOGGER.warning("Failed to fetch events from group %s: %s", urlname, e)
continue
Loading