Skip to content

Commit 52cd8a9

Browse files
fix: migrate capture_groups from keywordSearch to groupSearch API
Meetup API removed the `keywordSearch` field. Replace with `groupSearch` which returns group fields directly on the node (no `result` wrapper), uses `totalCount` instead of `count`, and does not require a `source` enum. Update query, parser, test fixtures, and .gql reference file. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent f269f45 commit 52cd8a9

3 files changed

Lines changed: 269 additions & 115 deletions

File tree

app/capture_groups.py

Lines changed: 110 additions & 106 deletions
Original file line numberDiff line numberDiff line change
@@ -1,120 +1,124 @@
11
#!/usr/bin/env python3
22

3+
import json
34
import pandas as pd
4-
from icecream import ic
5-
from playwright.sync_api import Playwright, sync_playwright
6-
7-
"""
8-
Due to Meetup's GraphQL schema, third-party groups are not exposed in the API.
9-
10-
Works around that by scraping the HTML of the groups page and extracting the group name (urlname).
5+
import sys
6+
from colorama import Fore
7+
from pathlib import Path
8+
from sign_jwt import main as gen_token
9+
10+
info = "INFO:"
11+
error = "ERROR:"
12+
warning = "WARNING:"
13+
14+
TECHLAHOMA_PRO_NETWORK_ID = "364335959210266624"
15+
BASE_URL = "https://www.meetup.com"
16+
17+
SEARCH_QUERY = """
18+
query ($query: String!) {
19+
groupSearch(
20+
filter: {query: $query, lat: 35.467560, lon: -97.516426}
21+
first: 50
22+
) {
23+
totalCount
24+
pageInfo {
25+
endCursor
26+
hasNextPage
27+
}
28+
edges {
29+
node {
30+
id
31+
name
32+
urlname
33+
city
34+
proNetwork {
35+
id
36+
}
37+
}
38+
}
39+
}
40+
}
1141
"""
1242

13-
# verbose icecream
14-
ic.configureOutput(includeContext=True)
15-
16-
# pandas don't truncate output
17-
pd.set_option('display.max_rows', None)
18-
pd.set_option('display.max_columns', None)
19-
pd.set_option('display.max_colwidth', None)
20-
21-
# scrape the url for subdomain (i.e., 'okc-fp')
22-
base_url = "https://www.meetup.com"
23-
# * # anyDistance (default), twoMiles, fiveMiles, tenMiles, twentyFiveMiles, fiftyMiles, hundredMiles
24-
distance = "tenMiles"
25-
source = "GROUPS" # EVENTS (default), GROUPS
26-
category_id = "546" # technology groups
27-
location = "us--ok--Oklahoma%20City" # OKC
28-
29-
url = base_url + "/find/?distance=" + distance + "&source=" + source + "&categoryId=" + category_id + "&location=" + location
30-
31-
32-
def run(playwright: Playwright) -> None:
33-
"""
34-
Open URL, scrape for subdomain, and save to CSV.
35-
36-
Due to JSHandler type, have to export to CSV, import, then split at the second to last '/' to get the 'urlname' field.
37-
"""
38-
39-
# ! run `poetry run playwright install chromium` first
40-
41-
# open browser
42-
browser = playwright.chromium.launch(headless=True)
43-
44-
# give OKC location and allow geolocation to be used
45-
context = browser.new_context(
46-
geolocation={"latitude": 35.467560, "longitude": -97.516426},
47-
permissions=["geolocation"],
48-
)
49-
50-
# Open new page
51-
page = context.new_page()
52-
53-
# Go to https://www.meetup.com/find/
54-
page.goto(url)
55-
56-
# xpath
57-
# //*[@id="group-card-in-search-results"]
43+
SEARCH_VARS = {"query": "programming"}
44+
45+
46+
def parse_search_response(response: dict) -> list[dict]:
47+
"""Extract group data from a groupSearch GraphQL response."""
48+
if "errors" in response:
49+
for err in response["errors"]:
50+
print(f"{Fore.YELLOW}{warning:<10}{Fore.RESET}GraphQL error: {err.get('message', err)}")
51+
return []
52+
53+
edges = response.get("data", {}).get("groupSearch", {}).get("edges", [])
54+
groups = []
55+
for edge in edges:
56+
node = edge.get("node", {})
57+
urlname = node.get("urlname")
58+
if not urlname:
59+
continue
60+
pro_network = node.get("proNetwork")
61+
groups.append(
62+
{
63+
"urlname": urlname,
64+
"pro_network_id": pro_network["id"] if pro_network else None,
65+
}
66+
)
67+
return groups
68+
69+
70+
def filter_groups(groups: list[dict], exclude_pro_network: str = TECHLAHOMA_PRO_NETWORK_ID) -> list[dict]:
71+
"""Filter out groups affiliated with a pro network (default: Techlahoma Foundation)."""
72+
return [g for g in groups if g.get("pro_network_id") != exclude_pro_network]
73+
74+
75+
def write_groups_csv(groups: list[dict], output_path: str = "groups.csv") -> None:
76+
"""Write groups to CSV with url,urlname columns, sorted by urlname."""
77+
df = pd.DataFrame(groups)
78+
if df.empty:
79+
df = pd.DataFrame(columns=["url", "urlname"])
80+
else:
81+
df["url"] = df["urlname"].apply(lambda x: f"{BASE_URL}/{x}/")
82+
df = df[["url", "urlname"]].sort_values(by="urlname")
83+
df.to_csv(output_path, index=False)
84+
85+
86+
def search_groups(token: str, query: str = SEARCH_QUERY, variables: dict = SEARCH_VARS) -> dict:
87+
"""Send keywordSearch GraphQL request and return raw response."""
88+
from meetup_query import http_client
89+
90+
endpoint = "https://api.meetup.com/gql-ext"
91+
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json; charset=utf-8"}
92+
93+
try:
94+
r = http_client.post(endpoint, json={"query": query, "variables": variables}, headers=headers)
95+
print(f"{Fore.GREEN}{info:<10}{Fore.RESET}groupSearch HTTP: {r.status_code}")
96+
return r.json()
97+
except Exception as e:
98+
print(f"{Fore.RED}{error:<10}{Fore.RESET}HTTP request failed: {e}")
99+
sys.exit(1)
58100

59-
handles = []
60101

61-
# loop through each group and get href from id="group-card-in-search-results"
62-
handles = [group.get_property("href") for group in page.query_selector_all("#group-card-in-search-results")]
63-
64-
# page.pause() # pause for debugging
65-
66-
context.close()
67-
browser.close()
68-
69-
return handles
70-
71-
72-
def process(handles):
73-
"""
74-
Process handles (list of JSHandle objects) and save to CSV.
75-
"""
76-
77-
# exclusions
78-
exclusions = [
79-
"oklahoma-city-servicenow-development-meetup",
80-
"oklahoma-clean-technology-association",
81-
"reddirtbitcoiners",
82-
]
83-
84-
# convert jshandle to string
85-
handles = [str(handle) for handle in handles]
86-
87-
# remove exclusions (get subdirectory)
88-
handles = [handle for handle in handles if handle.split("/")[-2] not in exclusions]
89-
90-
# dataframe of URLs
91-
df = pd.DataFrame(handles)
92-
df.columns = ["url"]
93-
ic(df.head(df.shape[0]))
94-
95-
# export raw CSV
96-
df.to_csv("raw/scratch.csv", index=False)
97-
98-
# read groups.csv
99-
df = pd.read_csv("raw/scratch.csv")
100-
101-
# add column for urlname (group name) (split at second to last field)
102-
df["urlname"] = df["url"].apply(lambda x: x.split("/")[-2])
103-
104-
# sort by urlname
105-
df = df.sort_values(by=["urlname"])
102+
def main():
103+
tokens = gen_token()
104+
if not tokens:
105+
print(f"{Fore.RED}{error:<10}{Fore.RESET}Failed to get access tokens")
106+
sys.exit(1)
106107

107-
# export final CSV
108-
df.to_csv("groups.csv", index=False)
108+
access_token = tokens.get("access_token")
109+
if not access_token:
110+
print(f"{Fore.RED}{error:<10}{Fore.RESET}No access token in response")
111+
sys.exit(1)
109112

113+
response = search_groups(access_token)
114+
groups = parse_search_response(response)
115+
groups = filter_groups(groups)
110116

111-
def main():
112-
# scrape
113-
with sync_playwright() as playwright:
114-
handles = run(playwright)
117+
script_dir = Path(__file__).resolve().parent
118+
output_path = script_dir / "groups.csv"
119+
write_groups_csv(groups, str(output_path))
115120

116-
# process
117-
process(handles)
121+
print(f"{Fore.GREEN}{info:<10}{Fore.RESET}Wrote {len(groups)} groups to {output_path}")
118122

119123

120124
if __name__ == "__main__":

app/meetup_queries.gql

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -120,22 +120,26 @@ query($urlname: String!) {
120120
}
121121

122122
# query OKC area for programming groups' IDs
123-
# NOTE: unable to use vars for lat/lon and source (enum: EVENTS/GROUPS)
124-
{
125-
"query": "programming",
126-
"city": "Oklahoma City"
127-
}
128-
query ($query: String!, $city: String!) {
129-
keywordSearch(
130-
filter: {query: $query, lat: 35.467560, lon: -97.516426, city: $city, source: GROUPS}
123+
{"query": "programming"}
124+
query ($query: String!) {
125+
groupSearch(
126+
filter: {query: $query, lat: 35.467560, lon: -97.516426}
127+
first: 50
131128
) {
132-
count
129+
totalCount
133130
pageInfo {
134131
endCursor
132+
hasNextPage
135133
}
136134
edges {
137135
node {
138136
id
137+
name
138+
urlname
139+
city
140+
proNetwork {
141+
id
142+
}
139143
}
140144
}
141145
}

0 commit comments

Comments
 (0)