11import { mkdir , readdir , readFile , rm , writeFile } from 'node:fs/promises' ;
22import path from 'node:path' ;
33
4- const API_URL = 'https://api.meetup.com/gql-ext' ;
54const GROUPS_FILE = path . join ( 'data' , 'meetup_groups.json' ) ;
65const CALENDAR_DIR = path . join ( 'content' , 'calendar' ) ;
76const 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 | d i v | l i | h [ 1 - 6 ] ) > / gi, '\n' )
43- . replace ( / < b r \s * \/ ? \s * > / gi, '\n' )
44- . replace ( / < [ ^ > ] * > / g, '' )
45- . replace ( / & n b s p ; / gi, ' ' )
46- . replace ( / & a m p ; / gi, '&' )
47- . replace ( / & l t ; / gi, '<' )
48- . replace ( / & g t ; / gi, '>' )
49- . replace ( / & q u o t ; / gi, '"' )
50- . replace ( / & # 3 9 ; / 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 ( / ^ e v e n t _ ( .+ ?) @ m e e t u p \. c o m $ / ) ;
60+ if ( match ) return match [ 1 ] ;
61+ const urlMatch = url . match ( / \/ e v e n t s \/ ( [ ^ / ? # ] + ) / ) ;
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 ( / < s c r i p t [ ^ > ] + t y p e = [ " ' ] a p p l i c a t i o n \/ l d \+ j s o n [ " ' ] [ ^ > ] * > ( [ \s \S ] * ?) < \/ s c r i p t > / 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 = / (?: O n l i n e | M i x e d ) E v e n t A t t e n d a n c e M o d e $ / . 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
75103async 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
118138async 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 ( / ^ e x t e r n a l U r l : \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 }
0 commit comments