Skip to content

Commit 0c728b1

Browse files
committed
Use public Meetup iCalendar feeds
1 parent 687c7e1 commit 0c728b1

3 files changed

Lines changed: 139 additions & 105 deletions

File tree

.github/scripts/sync-meetup-events.mjs

Lines changed: 116 additions & 103 deletions
Original file line numberDiff line numberDiff line change
@@ -1,75 +1,103 @@
11
import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises';
22
import path from 'node:path';
33

4-
const API_URL = 'https://api.meetup.com/gql-ext';
54
const GROUPS_FILE = path.join('data', 'meetup_groups.json');
65
const CALENDAR_DIR = path.join('content', 'calendar');
76
const DRY_RUN = process.argv.includes('--dry-run');
87

9-
const QUERY = `
10-
query UpcomingGroupEvents($urlname: ID!) {
11-
group(urlname: $urlname) {
12-
name
13-
events(input: { first: 100, filter: { status: "UPCOMING" } }) {
14-
edges {
15-
node {
16-
id
17-
title
18-
description
19-
dateTime
20-
eventUrl
21-
type
22-
venue {
23-
name
24-
address
25-
city
26-
state
27-
country
28-
}
29-
}
30-
}
31-
}
8+
function yaml(value) {
9+
return JSON.stringify(value ?? '');
10+
}
11+
12+
function unescapeIcal(value = '') {
13+
return value
14+
.replace(/\\n/gi, '\n')
15+
.replace(/\\,/g, ',')
16+
.replace(/\\;/g, ';')
17+
.replace(/\\\\/g, '\\');
18+
}
19+
20+
function property(line) {
21+
const separator = line.indexOf(':');
22+
if (separator < 1) return null;
23+
const declaration = line.slice(0, separator);
24+
return { name: declaration.split(';', 1)[0], value: line.slice(separator + 1) };
25+
}
26+
27+
function eventsFromIcal(calendar) {
28+
const events = [];
29+
let event;
30+
let groupName;
31+
32+
for (const line of calendar.replace(/\r?\n[ \t]/g, '').split(/\r?\n/)) {
33+
if (line === 'BEGIN:VEVENT') {
34+
event = {};
35+
continue;
36+
}
37+
if (line === 'END:VEVENT') {
38+
if (event) events.push(event);
39+
event = undefined;
40+
continue;
3241
}
42+
43+
const parsed = property(line);
44+
if (!parsed) continue;
45+
if (event) event[parsed.name] = parsed.value;
46+
if (parsed.name === 'X-WR-CALNAME') groupName = unescapeIcal(parsed.value);
3347
}
34-
`;
3548

36-
function yaml(value) {
37-
return JSON.stringify(value ?? '');
49+
return { events, groupName };
3850
}
3951

40-
function plainText(html = '') {
41-
return html
42-
.replace(/<\/(?:p|div|li|h[1-6])>/gi, '\n')
43-
.replace(/<br\s*\/?\s*>/gi, '\n')
44-
.replace(/<[^>]*>/g, '')
45-
.replace(/&nbsp;/gi, ' ')
46-
.replace(/&amp;/gi, '&')
47-
.replace(/&lt;/gi, '<')
48-
.replace(/&gt;/gi, '>')
49-
.replace(/&quot;/gi, '"')
50-
.replace(/&#39;/gi, "'")
51-
.replace(/\n{3,}/g, '\n\n')
52-
.trim();
52+
function date(value, field, url) {
53+
const match = value?.match(/^(\d{4})(\d{2})(\d{2})/);
54+
if (!match) throw new Error(`Meetup event ${url} has no valid ${field}.`);
55+
return `${match[1]}-${match[2]}-${match[3]}`;
5356
}
5457

55-
function venueLabel(venue, groupName, isVirtual) {
56-
if (isVirtual) return 'Online';
58+
function eventId(event, url) {
59+
const match = event.UID?.match(/^event_(.+?)@meetup\.com$/);
60+
if (match) return match[1];
61+
const urlMatch = url.match(/\/events\/([^/?#]+)/);
62+
if (urlMatch) return urlMatch[1];
63+
throw new Error(`Meetup calendar event has no recognized ID: ${url}`);
64+
}
5765

58-
const parts = [venue?.name, venue?.address, venue?.city, venue?.state, venue?.country]
59-
.filter(Boolean);
60-
return parts.length ? [...new Set(parts)].join(', ') : groupName;
66+
function eventSchema(html, url) {
67+
const scripts = [...html.matchAll(/<script[^>]+type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi)];
68+
for (const [, script] of scripts) {
69+
const value = JSON.parse(script);
70+
const candidates = Array.isArray(value) ? value : value['@graph'] ?? [value];
71+
const event = candidates.find(({ '@type': type }) => type === 'Event');
72+
if (event) return event;
73+
}
74+
throw new Error(`Meetup event page has no Event structured data: ${url}`);
6175
}
6276

63-
function isVirtual(event) {
64-
return ['ONLINE', 'HYBRID'].includes(event.type);
77+
function eventMetadata(event) {
78+
const virtual = /(?:Online|Mixed)EventAttendanceMode$/.test(event.eventAttendanceMode ?? '');
79+
if (virtual && event.location?.['@type'] === 'VirtualLocation') {
80+
return { virtual, where: 'Online' };
81+
}
82+
83+
const address = event.location?.address;
84+
const addressParts = typeof address === 'string'
85+
? [address]
86+
: [address?.streetAddress, address?.addressLocality, address?.addressRegion, address?.addressCountry];
87+
const where = [event.location?.name, ...addressParts].filter(Boolean).join(', ');
88+
return { virtual, where };
6589
}
6690

67-
function eventFile(event, groupName) {
68-
const virtual = isVirtual(event);
69-
const startDate = event.dateTime.slice(0, 10);
70-
const body = plainText(event.description);
91+
function eventFile(event, metadata, groupName) {
92+
const url = event.URL;
93+
if (!url) throw new Error('Meetup calendar event has no URL.');
94+
95+
const startDate = date(event.DTSTART, 'start date', url);
96+
const endDate = event.DTEND ? date(event.DTEND, 'end date', url) : undefined;
97+
const endDateField = endDate && endDate !== startDate ? `endDate: ${yaml(endDate)}\n` : '';
98+
const description = unescapeIcal(event.DESCRIPTION).trim();
7199

72-
return `---\nmeetupEventId: ${yaml(String(event.id))}\nmeetupSource: meetup\nstartDate: ${yaml(startDate)}\ntitle: ${yaml(event.title)}\nexternalUrl: ${yaml(event.eventUrl)}\nvirtual: ${virtual}\nwhere: ${yaml(venueLabel(event.venue, groupName, virtual))}\n---\n${body}\n`;
100+
return `---\nmeetupEventId: ${yaml(eventId(event, url))}\nmeetupSource: meetup\nstartDate: ${yaml(startDate)}\n${endDateField}title: ${yaml(unescapeIcal(event.SUMMARY))}\nexternalUrl: ${yaml(url)}\nvirtual: ${metadata.virtual}\nwhere: ${yaml(metadata.where || groupName)}\n---\n${description}\n`;
73101
}
74102

75103
async function groups() {
@@ -80,79 +108,64 @@ async function groups() {
80108
return parsed;
81109
}
82110

83-
async function fetchEvents(urlname, token) {
84-
const response = await fetch(API_URL, {
85-
method: 'POST',
86-
headers: {
87-
Authorization: `Bearer ${token}`,
88-
'Content-Type': 'application/json',
89-
},
90-
body: JSON.stringify({ query: QUERY, variables: { urlname } }),
91-
});
92-
93-
if (!response.ok) {
94-
throw new Error(`Meetup returned ${response.status} for ${urlname}.`);
95-
}
111+
async function fetchGroupEvents(urlname) {
112+
const response = await fetch(`https://www.meetup.com/${urlname}/events/ical/`);
113+
if (!response.ok) throw new Error(`Meetup iCalendar feed returned ${response.status} for ${urlname}.`);
96114

97-
const result = await response.json();
98-
if (result.errors?.length) {
99-
throw new Error(`Meetup query failed for ${urlname}: ${result.errors.map(({ message }) => message).join('; ')}`);
100-
}
101-
if (!result.data?.group) {
102-
throw new Error(`Meetup group ${urlname} was not found or is not accessible to this token.`);
103-
}
115+
const calendar = eventsFromIcal(await response.text());
116+
const events = await Promise.all(calendar.events.map(async (event) => {
117+
if (event.STATUS === 'CANCELLED') return { event };
118+
if (!event.URL || !event.DTSTART || !event.SUMMARY) {
119+
throw new Error(`Meetup calendar event for ${urlname} is missing required fields.`);
120+
}
121+
122+
const page = await fetch(event.URL);
123+
if (!page.ok) throw new Error(`Meetup event page returned ${page.status}: ${event.URL}`);
124+
return { event, metadata: eventMetadata(eventSchema(await page.text(), event.URL)) };
125+
}));
104126

105-
return result.data.group;
127+
return { events, groupName: calendar.groupName || urlname };
106128
}
107129

108-
async function managedFiles() {
130+
async function calendarFiles() {
109131
const names = await readdir(CALENDAR_DIR);
110-
const files = await Promise.all(names.filter((name) => name.endsWith('.md')).map(async (name) => {
111-
const file = path.join(CALENDAR_DIR, name);
112-
const content = await readFile(file, 'utf8');
113-
return content.includes('meetupSource: meetup') ? file : null;
114-
}));
115-
return files.filter(Boolean);
132+
return Promise.all(names.filter((name) => name.endsWith('.md')).map(async (name) => ({
133+
file: path.join(CALENDAR_DIR, name),
134+
content: await readFile(path.join(CALENDAR_DIR, name), 'utf8'),
135+
})));
116136
}
117137

118138
async function main() {
119-
const token = process.env.MEETUP_ACCESS_TOKEN;
120-
if (!token) {
121-
throw new Error('MEETUP_ACCESS_TOKEN is required. Create it from a Meetup OAuth client and store it as a repository secret.');
122-
}
123-
124139
const configuredGroups = await groups();
125-
const result = await Promise.all(configuredGroups.map(({ urlname }) => fetchEvents(urlname, token)));
140+
const groupEvents = await Promise.all(configuredGroups.map(({ urlname }) => fetchGroupEvents(urlname)));
141+
await mkdir(CALENDAR_DIR, { recursive: true });
142+
143+
const calendar = await calendarFiles();
144+
const managed = new Set(calendar.filter(({ content }) => content.includes('meetupSource: meetup')).map(({ file }) => file));
145+
const manualUrls = new Set(calendar
146+
.filter(({ content }) => !content.includes('meetupSource: meetup'))
147+
.map(({ content }) => content.match(/^externalUrl:\s*["']?([^\s"']+)/m)?.[1])
148+
.filter(Boolean));
126149
const desired = new Map();
127150

128-
for (const group of result) {
129-
for (const { node: event } of group.events.edges) {
130-
if (!event.id || !event.dateTime || !event.eventUrl || !event.title) {
131-
throw new Error(`Meetup event from ${group.name} is missing required calendar fields.`);
132-
}
133-
desired.set(path.join(CALENDAR_DIR, `meetup-${event.id}.md`), eventFile(event, group.name));
151+
for (const { events, groupName } of groupEvents) {
152+
for (const { event, metadata } of events) {
153+
if (event.STATUS === 'CANCELLED' || manualUrls.has(event.URL)) continue;
154+
desired.set(path.join(CALENDAR_DIR, `meetup-${eventId(event, event.URL)}.md`), eventFile(event, metadata, groupName));
134155
}
135156
}
136157

137-
await mkdir(CALENDAR_DIR, { recursive: true });
138-
const existing = new Set(await managedFiles());
139158
const changes = [];
140-
141159
for (const [file, content] of desired) {
142-
let current;
143-
try {
144-
current = await readFile(file, 'utf8');
145-
} catch (error) {
146-
if (error.code !== 'ENOENT') throw error;
147-
}
160+
const current = calendar.find((entry) => entry.file === file)?.content;
148161
if (current !== content) {
149162
changes.push(`${current === undefined ? 'add' : 'update'} ${file}`);
150163
if (!DRY_RUN) await writeFile(file, content);
151164
}
152-
existing.delete(file);
165+
managed.delete(file);
153166
}
154167

155-
for (const file of existing) {
168+
for (const file of managed) {
156169
changes.push(`remove ${file}`);
157170
if (!DRY_RUN) await rm(file);
158171
}

.github/workflows/meetup-sync.yml

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,6 @@ jobs:
2626
node-version: '20'
2727

2828
- name: Sync upcoming Meetup events
29-
env:
30-
MEETUP_ACCESS_TOKEN: ${{ secrets.MEETUP_ACCESS_TOKEN }}
3129
run: node .github/scripts/sync-meetup-events.mjs
3230

3331
- name: Create pull request for synchronized events
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
meetupEventId: "316283278"
3+
meetupSource: meetup
4+
startDate: "2026-09-02"
5+
title: "NeoVIM for PowerShell!"
6+
externalUrl: "https://www.meetup.com/research-triangle-powershell-users-group/events/316283278/"
7+
virtual: true
8+
where: "Online"
9+
---
10+
Research Triangle PowerShell Users Group
11+
PowerShell development doesn't require VSCode. In this session, Rob shares his approach to building an efficient, terminal-first workflow using NeoVIM—and demonstrates how this setup powers real work in DevOps, Cloud Security, and Application Security environments.
12+
13+
This session explores alternative approaches to PowerShell development—specifically, building an efficient workflow without relying on traditional GUI-based IDEs.
14+
Drawing from 13+ years across System Administration, DevOps, Cloud Security, and Application Security, Rob Pleau shares the tools, configurations, and strategies that enable productive PowerShell development in a terminal environment.
15+
16+
**Topics include:**
17+
18+
* Why terminal-first workflows can be more efficient for certain tasks
19+
* Editor options and setup for cross-platform consistency
20+
* Practical tooling and configurations
21+
* Real-world examples from professional PowerShell work
22+
* Tips applicable to any development environment
23+
*

0 commit comments

Comments
 (0)