Meetup Event Search

Site meetup.comTask search-events-8bdsvyVersion v1Updated Jul 13, 2026Category events

Search Meetup for upcoming events by topic, location, and filters, returning each event (title, group, venue, time, RSVP count, price) plus region-wide totals and pagination cursors as structured JSON. This skill was captured from a live agent session on meetup.com and publishes here verbatim, exactly as an agent receives it.

NoteSelectors and URL schemes drift as sites change. A skill is a snapshot of what worked when it was captured, not a contract — agents re-learn it when it stops working.

Purpose

Search Meetup for upcoming events matching a topic and location (and any of Meetup's filter dimensions) and return the matching events as structured JSON, plus the region-wide total and a pagination cursor so the caller knows the returned slice is partial. Read-only — never RSVP, join, save, or sign in. The entire first page of results is server-rendered into the /find/ page's HTML, so no scripted clicking, scrolling, or GraphQL reverse-engineering is needed for the common case.

When to Use

  • "Find AI events in San Francisco", "book clubs in Brooklyn", "climbing meetups near 94110".
  • Filtered discovery: a topic category, a date window (today / this weekend / next week), in-person vs online, a distance radius, free vs paid, sort by date/distance.
  • Monitoring a city + topic for new upcoming events on a schedule.
  • A Meetup search URL (https://www.meetup.com/find/?...) you want decoded into structured data.

Workflow

The optimal path is a single browserless_agent navigation to the /find/ page through a residential proxy. The page is a server-rendered Next.js app: the complete eventSearch GraphQL connection (results, total count, and pagination cursor) plus every normalized Event / Group object is embedded in a <script id="__NEXT_DATA__"> JSON blob before any JS runs. Read that blob off the DOM and you have the data — no scripted clicking or GraphQL reverse-engineering for the common case. (Meetup sits behind Cloudflare; a residential proxy is required — a bare request from a datacenter IP risks a 403/challenge.)

  1. Build the search URL. Base: https://www.meetup.com/find/?source=EVENTS. Append the filters the caller asked for (param names below; all are URL query params):

    DimensionParamValue
    Topic keywordskeywordsfree text, e.g. AI, book club
    Locationlocationslug form us--ca--San Francisco (URL-encoded); free text San Francisco, CA also geocodes server-side. For lat/lon use lat= + lon=.
    CategorycategoryIdnumeric taxonomy id (see Gotchas table)
    Date windowdateRangetoday, tomorrow, this_week, this_weekend, next_week; or customStartDate= + customEndDate= (ISO)
    FormateventTypeinPerson, online (omit for both)
    Distancedistancemiles from center (e.g. distance=10)
    Event typeeventType (fee)paid / free is implicit; price not filterable via URL
    SortsortFieldRELEVANCE (default), DATETIME (date), DISTANCE
  2. Navigate through a residential proxy. Issue a browserless_agent call carrying the top-level arg proxy: { proxy: "residential" } and a single { "method": "goto", "params": { "url": "<url>", "waitUntil": "load", "timeout": 45000 } } (never networkidle). The session persists across calls, keyed by proxy/profile, so keep the goto and the extract below in the same commands array to save round-trips and avoid dropping the session config.

  3. Extract __NEXT_DATA__ in-page. Read the blob straight off the DOM with { "method": "evaluate", "params": { "content": "(()=>JSON.stringify(JSON.parse(document.querySelector('#__NEXT_DATA__').textContent)))()" } } — the result comes back under .value. Better still, walk to the Apollo store (next step) inside the same evaluate and return only the projected fields; the full blob can be large and the text return is ~200k-char capped.

  4. Read the Apollo store. props.pageProps.__APOLLO_STATE__ is a normalized cache:

    • ROOT_QUERY has a key beginning eventSearch(...){ totalCount, pageInfo { hasNextPage, endCursor }, edges[] }. totalCount is the region-wide total; endCursor (base64, e.g. "MTI=" = "12") is the pagination cursor.
    • Each edges[i].node.__ref is a string like "Event:314707414". Look that key up in __APOLLO_STATE__ for the full event.
    • Each Event's group.__ref ("Group:<id>") resolves to the hosting group's name + urlname.
    • ROOT_QUERY also carries locationSearch({"query":"..."}) — the geocoded center (lat/lon/zip/timeZone/name) Meetup resolved the location text to. Use it to confirm the search landed in the right city.
  5. Map each Event to output (field names as they appear in the Apollo Event object):

    • id, title, dateTime (start, ISO 8601 w/ tz), description (full body), eventType (PHYSICAL | ONLINE | HYBRID), eventUrl (canonical).
    • venue is inline on the Event: { name, address, city, state, country } for in-person; for online events venue is null/empty and the platform link lives only on the event detail page.
    • maxTickets = capacity; rsvps.totalCount = RSVP count; feeSettings === null ⇒ free (a non-null feeSettings carries the price).
    • featuredEventPhoto / displayPhoto are PhotoInfo:<id> refs (resolve in the store for the image URL); series describes recurrence; socialProofInsights.totalInterestedUsers is the "interested" count.
  6. Emit JSON in the Expected Output shape. Mark the slice partial whenever pageInfo.hasNextPage is true and surface endCursor so the caller can paginate.

Pagination beyond page 1 (only if needed)

The SSR blob is page 1 (the first ~12 ranked events; the store often pre-hydrates up to ~30). For deeper pages the site's own client POSTs to the persisted-query GraphQL endpoint https://www.meetup.com/gql2 with { operationName, variables: { cursor }, extensions: { persistedQuery: { sha256Hash } } }. The hash + operation name rotate per front-end deploy and the endpoint is behind robots: Disallow: /gql*, so don't hardcode them — for most callers, re-fetching /find/ with a larger result window or a tightened filter (category/date/distance) is simpler and more durable than chasing the GraphQL cursor.

Handling a Cloudflare challenge

If the navigation gets challenged or rate-limited, escalate within the same browserless_agent call by prepending a { "method": "solve", "params": { "type": "cloudflare" } } command before the goto, then read the same blob:

  1. { "method": "goto", "params": { "url": "<find-url>", "waitUntil": "load", "timeout": 45000 } } (with proxy: { proxy: "residential" } on the call).
  2. { "method": "text", "params": { "selector": "script#__NEXT_DATA__" } } or an evaluate on that selector — NOT { "method": "snapshot" }; the data lives in a script tag, not the accessibility tree, so snapshot returns nothing useful.
  3. Parse __NEXT_DATA__ exactly as in steps 4–5 above.

Verified working end-to-end (find page returned HTTP 200, full event set extracted) across two iterations.

Site-Specific Gotchas

  • Cloudflare — proxy is mandatory, stealth helps. Meetup fronts everything with Cloudflare. The /find/ navigation succeeds with a browserless_agent residential proxy (proxy: { proxy: "residential" }); if it's still challenged, add a solve { type: "cloudflare" } command. A datacenter IP risks a 403 / "Just a moment" challenge. In a clean traced run only a single .woff2 font request 403'd and one telemetry beacon 422'd — the find-page document itself returned 200, so don't mistake noisy sub-resource failures for a block; check the status of the /find/?... document specifically.

  • Read the blob off the live DOM, not a text envelope. A raw HTTP fetch used to print an Update available: banner you had to strip before JSON.parse; with a browserless_agent you read document.querySelector('#__NEXT_DATA__').textContent directly via evaluate. Wrap the projection in JSON.stringify(...) (the return arrives under .value) and project in-page — the text return is ~200k-char capped, so don't ship the whole blob.

  • The data is in a <script> tag, not the DOM you'd click. Read #__NEXT_DATA__ via an evaluate (or { "method": "text", "params": { "selector": "script#__NEXT_DATA__" } }); a { "method": "snapshot" } returns no useful refs for results — the accessibility tree doesn't include script contents.

  • endCursor is base64. "MTI=" decodes to "12" — it's an opaque offset cursor, not the count. totalCount is the real region-wide total.

  • __APOLLO_STATE__ contains more Event:* keys than the search returned. A search for AI in SF yielded totalCount: 30 but ~54 Event: objects in the store — the extras are sparse stubs (id, dateTime, group only) referenced by group cards / series, NOT search hits. Only treat the refs inside the eventSearch connection's edges[] as results; iterating every Event:* key pollutes the output with non-matching events.

  • Category taxonomy is a numeric enum (pass via categoryId=). Confirmed from the live front-end bundle:

    CategoryidCategoryid
    Technology546Music395
    Career & Business405Health & Wellbeing511
    Art & Culture521Sports & Fitness482
    Science & Education436Social Activities652
    Hobbies & Passions571Games535
    Community & Environment604Identity & Language622
    Movements & Politics642Religion & Spirituality593
    Travel & Outdoor684Parents & Family673
    Pets & Animals701Support & Coaching449
    Dancing612Writing467

    Meetup's live taxonomy is finer-grained than the prompt's 15-bucket list (e.g. "Outdoors & Adventure" maps to Travel & Outdoor 684; "Arts & Culture" → Art & Culture 521). Verified categoryId=546 returns Technology events.

  • Location resolution. location= accepts both the slug form (us--ca--San Francisco) and free text (San Francisco, CA); both geocode server-side, and the resolved center appears under ROOT_QUERY.locationSearch(...). With no location, results geolocate to the proxy/request IP — always pass an explicit location (or lat/lon) for deterministic output. "Online" searches still report a nominal city in the header but the eventType=online filter scopes the results.

  • No end time / duration / venue lat-lon in the SSR blob. The /find/ query selects dateTime (start) but not end time, duration, or venue coordinates. For those, plus online-platform links (Zoom/Meet) and rich group fields (member count, About, founded date), fetch the individual event page or group page (each has its own richer __APOLLO_STATE__). The search-page Group object is sparse: name, urlname, timezone, rating stats only.

  • gql2 is a persisted-query endpoint behind robots. Don't try to call it with a raw GraphQL query string — it expects an extensions.persistedQuery.sha256Hash that rotates per deploy. The SSR blob already contains page 1, so you rarely need it.

  • robots.txt disallows the search query params (keywords, location, distance, dateRange, categoryId, sortField, …) and /gql*. This is read-only extraction of public listing data; throttle politely (≤1 req/s) and prefer the single SSR fetch over hammering the GraphQL cursor.

Expected Output

{
  "success": true,
  "query": {
    "topic": "AI",
    "location": "San Francisco, CA",
    "category_id": null,
    "sort": "RELEVANCE"
  },
  "resolved_center": {
    "city": "San Francisco",
    "state": "CA",
    "country": "us",
    "lat": 37.78,
    "lon": -122.42,
    "zip": "94101",
    "timezone": "US/Pacific"
  },
  "total_count": 30,
  "has_next_page": true,
  "end_cursor": "MTI=",
  "events": [
    {
      "id": "314707414",
      "title": "John Vervaeke - How Minds Find What Matters",
      "description": "For this session, we'll be reading John Vervaeke … relevance realization …",
      "start_time": "2026-06-03T18:00:00-07:00",
      "event_type": "PHYSICAL",
      "is_online": false,
      "venue": {
        "name": "The Fold",
        "address": "3359 26th St, San Francisco, CA 94110, USA",
        "city": "San Francisco",
        "state": "CA",
        "country": "us"
      },
      "rsvp_count": 50,
      "capacity": 50,
      "is_free": true,
      "price": null,
      "photo_url": null,
      "interested_count": 40,
      "is_recurring": true,
      "group": {
        "name": "San Francisco Philosophy Reading Group",
        "urlname": "sf-philosophy-reading-group",
        "url": "https://www.meetup.com/sf-philosophy-reading-group/",
        "rating": 4.81
      },
      "event_url": "https://www.meetup.com/sf-philosophy-reading-group/events/314707414/"
    }
  ],
  "error_reasoning": null
}

Online-event shape (in-person fields null, event_type: "ONLINE"; platform link is only on the event detail page):

{
  "id": "313346768",
  "title": "Weekly AI Paper Discussion",
  "start_time": "2026-06-05T18:00:00-07:00",
  "event_type": "ONLINE",
  "is_online": true,
  "venue": null,
  "online_platform": null,
  "rsvp_count": 18,
  "capacity": null,
  "is_free": true,
  "group": {
    "name": "SF AI",
    "urlname": "sfbay-ai",
    "url": "https://www.meetup.com/sfbay-ai/"
  },
  "event_url": "https://www.meetup.com/sfbay-ai/events/313346768/"
}

Blocked / failure shape:

{
  "success": false,
  "query": { "topic": "AI", "location": "San Francisco, CA" },
  "total_count": null,
  "events": [],
  "error_reasoning": "Cloudflare challenge / HTTP 403 on /find/ — retry with a residential-proxy browserless_agent plus a solve command, or the residential proxy IP is flagged."
}

Call it

GET https://production-sfo.browserless.io/skills?token=TOKEN-HERE&domain=meetup.com&task=search-events-8bdsvy