
No CSS selectors, no headless browser. The data was in the HTML all along.
This week I built one that collects upcoming events in a city from Eventbrite, Meetup and Luma and puts them in one list. I expected three scrapers full of fragile selectors. I ended up writing almost none, because all three sites already ship their event data inside the page, in a form a program can read. Each one just keeps it somewhere different.
🚦 First, the rules
Before writing any code I read each site's robots.txt, keeping only the rules that apply to every bot (User-agent: *):
- Eventbrite blocks its RSS and Atom feeds, checkout links and a few API paths. The city search pages (
/d/...) and event pages are open. - Meetup blocks its API paths (
/api,/meetup_api) and its feeds. The search page (/find/) and event pages are open. - Luma's robots.txt only has rules for Googlebot.
robots.txt isn't the whole story, so read each site's terms as well before you build on it. I also send an honest User-Agent that names the tool, and wait 1.5 seconds between requests to the same site.
🧩 Hiding place 1: schema.org JSON-LD
All three sites put a <script type="application/ld+json"> block in their pages, with each event described in schema.org's Event vocabulary. It's there for search engines, and it's the most standard of the three hiding places:
{ "@type": "Event", "name": "Tech Networking Event In NYC", "startDate": "2026-11-18", "location": { "@type": "Place", "name": "Tap Haus 33", "address": { "streetAddress": "14 East 33rd Street", "addressLocality": "New York" }, "geo": { "latitude": "40.7473297", "longitude": "-73.9842987" } } } The events can be nested inside @graph arrays or ItemLists, and the type can be MusicEvent or BusinessEvent rather than plain Event. So instead of guessing the shape, I walk the whole object and keep anything whose type ends in "Event":
export function jsonLdEvents(html) { const events = []; const walk = (node) => { if (!node || typeof node !== 'object') return; if (Array.isArray(node)) return node.forEach(walk); const type = [].concat(node['@type'] ?? []).join(' '); if (/Event\b/.test(type)) events.push(node); for (const value of Object.values(node)) walk(value); }; for (const m of html.matchAll(/<script[^>]*application\/ld\+json[^>]*>([\s\S]*?)<\/script>/gi)) { try { walk(JSON.parse(m[1])); } catch { /* one broken block shouldn't hide the rest */ } } return events; } JSON-LD is great for the basics. But it's the search-engine version of each event, so it leaves things out. Meetup's had no coordinates and no end time, and Eventbrite's search page had no prices. For the rest, I went to the data each site's own front end uses.
🧩 Hiding place 2: a JavaScript object (Eventbrite)
Eventbrite's search page is about 800 KB, and most of that is one line:
window.__SERVER_DATA__ = {"env":{...},"search_data":{"events":{"results":[...],"pagination":{...}}}, ...}; It isn't a JSON script tag, so you can't just grab a script's contents and parse them. It's a JavaScript assignment, followed by more code. I find the marker, then walk forward counting braces until the object closes, skipping anything inside strings. Event descriptions are full of { and }, so strings have to be skipped, escaped quotes included:
export function objectAfter(html, marker) { const start = html.indexOf('{', html.indexOf(marker)); let depth = 0, inString = false, escaped = false; for (let i = start; i < html.length; i++) { const c = html[i]; if (inString) { if (escaped) escaped = false; else if (c === '\\') escaped = true; else if (c === '"') inString = false; continue; } if (c === '"') inString = true; else if (c === '{') depth++; else if (c === '}' && --depth === 0) return JSON.parse(html.slice(start, i + 1)); } return null; } That object holds 20 events a page, each with the venue's full address, coordinates, time zone, categories and an is_online_event flag, plus page_count for paging with ?page=2.
🧩 Hiding place 3: Next.js page data (Luma and Meetup)
Luma and Meetup are both Next.js apps, so each page carries <script id="__NEXT_DATA__"> with the props it was rendered from.
Luma is the easy one. A city page like luma.com/london has its events at props.pageProps.initialData.data.events, with the ticket price in cents, whether it's free, the guest count and the host calendar. Luma's discover page lists the cities it covers (87 when I checked), each with coordinates, so I pick the nearest one to the place the user typed.
Meetup ships an Apollo cache instead, which is normalized: objects point at each other by reference rather than nesting.
const state = nextData.props.pageProps.__APOLLO_STATE__; // ROOT_QUERY['eventSearch:{"filter":{"city":"New York",...}}'] = { edges: [{ node: { __ref: 'Event:316963808' } }, ...] } // state['Event:316963808'] = { title, dateTime, venue, group: { __ref: 'Group:11913282' }, rsvps: { totalCount: 147 }, ... } const resolve = (v) => (v && v.__ref ? state[v.__ref] : v); The cache key even records the filter Meetup actually applied, city and country included. That turned out to matter.
🐛 The bug: zero Meetup events, but only sometimes
With a keyword ("tech"), Meetup returned about 20 to 30 events per city. Without one, it returned zero for every city. No error, just nothing.
The page wasn't empty. Without a keyword, Meetup doesn't run a search at all. It shows recommended events, and those are cached under a different key with a different shape:
// with a keyword: eventSearch:{...} -> edges: [{ node: { __ref: 'Event:1' } }] // without a keyword: recommendedEvents:{...} -> edges: [{ __ref: 'RecommendedEventsEdge:{"node":{"id":"1"}}' }] Each edge is a reference too, so the event is two hops away. The fix was to accept either key and resolve twice:
const key = keys.find((k) => k.startsWith('eventSearch')) ?? keys.find((k) => k.startsWith('recommendedEvents')); const events = state.ROOT_QUERY[key].edges.map((edge) => resolve(resolve(edge)?.node)); Lesson: when a parser returns zero, test with and without every optional input. "Empty" was a different page shape, not an empty page.
📍 Every site wants the place spelled differently
The user types "London". Each site wants something else:
| Site | What it wants |
|---|---|
| Eventbrite | /d/united-kingdom--london/ (/d/ny--new-york/ in the US) |
| Meetup | location=gb--London (us--ny--New York in the US and Canada) |
| Luma | the nearest of its city slugs: london |
So the first step is a geocoder (OpenStreetMap's Nominatim) for the country, the state code and coordinates. It had two surprises:
- OpenStreetMap calls London "Greater London", and New York "City of New York". My first London run searched "Greater London" on Eventbrite and Meetup. (Luma was fine, because it goes by coordinates.) Now a prefix like that is dropped when what's left is what the user typed.
- Meetup quietly falls back to your own location. When I sent a location string it didn't understand (
location=london, or bare coordinates), it didn't fail. It returned events near my IP address, which is in Nairobi. The venues gave it away: every one of them was in or around Nairobi. Now every source checks the place it actually searched, and a mismatch becomes an error row for that city instead of wrong events.
A real mismatch needs care too. Eventbrite searches "Brooklyn" as New York, which is fair, since Brooklyn is part of the city. So the check accepts the parent city the geocoder reports.
🕐 Three time formats, one output
- Eventbrite:
"start_date": "2026-11-18", "start_time": "18:30", "timezone": "America/New_York" - Luma:
"start_at": "2026-11-08T23:00:00.000Z"plus a time zone - Meetup:
"dateTime": "2026-11-10T19:30:00-05:00"
I output local time with the offset (2026-11-18T18:30:00-05:00), because that's when people turn up, and it still converts cleanly. Node's Intl gives the offset for any zone at a given moment:
function offsetAt(timeZone, utcMs) { const name = new Intl.DateTimeFormat('en-US', { timeZone, timeZoneName: 'longOffset' }) .formatToParts(new Date(utcMs)).find((p) => p.type === 'timeZoneName').value; // "GMT-05:00" const m = name.match(/GMT([+-])(\d{1,2}):?(\d{2})?/); return m ? `${m[1]}${m[2].padStart(2, '0')}:${m[3] ?? '00'}` : '+00:00'; } For Eventbrite's local times there's a catch: to know the offset you need the instant, and to know the instant you need the offset. I guess with the naive time, then ask again at the corrected instant, so a time near a daylight-saving change lands on the right side. The unit tests cover New York in November (-05:00) and July (-04:00), London (+00:00) and Kolkata (+05:30).
🔁 The same event on two sites
Organizers often list one event in two places. A test run in London found "Tech-Baithak London Launch Meet" on both Eventbrite and Meetup. The merge key is deliberately simple: the title's words, lowercased and stripped of punctuation, plus the local start date. The first listing is kept, the other goes into an alsoListedOn field, and any field the first listing lacks (coordinates, price) is filled from the second.
🎯 What I learned
- Look for the data before you write selectors. JSON-LD,
__NEXT_DATA__and server data objects survive redesigns that break CSS selectors. - Every site hides it differently. One extractor for each kind of hiding place, plus one normalizer, beat three full scrapers.
- Normalized caches need resolving. In Apollo's cache, even the list edges can be references.
- Check what the site actually searched. A search that falls back to your IP location looks exactly like success.
- Test every optional input both ways. My zero-results bug only appeared without a keyword.

