|
1 | 1 | #!/usr/bin/env python3 |
2 | 2 |
|
| 3 | +import json |
3 | 4 | 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 | +} |
11 | 41 | """ |
12 | 42 |
|
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) |
58 | 100 |
|
59 | | - handles = [] |
60 | 101 |
|
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) |
106 | 107 |
|
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) |
109 | 112 |
|
| 113 | + response = search_groups(access_token) |
| 114 | + groups = parse_search_response(response) |
| 115 | + groups = filter_groups(groups) |
110 | 116 |
|
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)) |
115 | 120 |
|
116 | | - # process |
117 | | - process(handles) |
| 121 | + print(f"{Fore.GREEN}{info:<10}{Fore.RESET}Wrote {len(groups)} groups to {output_path}") |
118 | 122 |
|
119 | 123 |
|
120 | 124 | if __name__ == "__main__": |
|
0 commit comments