diff --git a/.gitignore b/.gitignore index b8b4661..26aa856 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ # testing /coverage +/.tmp-meetup-tests/ # next.js /.next/ diff --git a/package.json b/package.json index 41754f8..8b841d4 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "lint": "next lint", "format:check": "prettier --check .", "quality": "tsc --noEmit", + "test:meetup": "tsc -p tests/tsconfig.json && node --test .tmp-meetup-tests/tests/meetup.test.js", "init:tailwind": "tailwindcss init tailwindcss.config.ts" }, "engines": { diff --git a/src/app/page.tsx b/src/app/page.tsx index 9cc675b..a334150 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -406,15 +406,7 @@ export default async function Home() { {past.length ? (
{past.slice(0, 3).map((event) => ( - + ))}
) : ( diff --git a/src/lib/meetup.ts b/src/lib/meetup.ts index 0145e83..6e3971c 100644 --- a/src/lib/meetup.ts +++ b/src/lib/meetup.ts @@ -37,8 +37,24 @@ type ApolloRecord = { highResUrl?: string } +type PastEventsPage = { + events: MeetupEvent[] + totalCount?: number + endCursor?: string + hasNextPage: boolean + beforeDateTime?: string +} + +type MeetupGraphQLEvent = ApolloRecord & { + venue?: { name?: string; city?: string } | null + displayPhoto?: { highResUrl?: string | null } | null + featuredEventPhoto?: { highResUrl?: string | null } | null +} + const EVENTS_URL = "https://www.meetup.com/it-IT/coding-bunker/events/" const PAST_EVENTS_URL = `${EVENTS_URL}?type=past` +const MEETUP_GRAPHQL_URL = "https://www.meetup.com/gql2" +const MEETUP_USER_AGENT = "CodingBunkerWebsite/1.0 (+https://codingbunker.it)" function getNextData(html: string): Record | undefined { const match = html.match( @@ -54,6 +70,190 @@ function getNextData(html: string): Record | undefined { } } +function isApolloRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null +} + +function toMeetupEvent( + event: ApolloRecord, + state?: Record, +): MeetupEvent | undefined { + if (!event.id || !event.title || !event.dateTime || !event.eventUrl) { + return undefined + } + + const venueReference = event.venue?.__ref + const venue = (venueReference ? state?.[venueReference] : event.venue) as + | ApolloRecord + | undefined + const photoReference = + event.displayPhoto?.__ref || event.featuredEventPhoto?.__ref + const photo = photoReference ? state?.[photoReference] : undefined + const graphQLEvent = event as MeetupGraphQLEvent + const coverImageUrl = + photo?.highResUrl || + graphQLEvent.displayPhoto?.highResUrl || + graphQLEvent.featuredEventPhoto?.highResUrl || + null + + return { + id: event.id, + title: event.title, + description: event.description || "", + dateTime: event.dateTime, + endTime: event.endTime, + eventUrl: event.eventUrl, + status: event.status || "", + venue: venue?.name || null, + city: venue?.city || null, + attendeeCount: event.going?.totalCount ?? null, + coverImageUrl: typeof coverImageUrl === "string" ? coverImageUrl : null, + } +} + +function getApolloState(page: Record | undefined) { + return ( + page as + | { + props?: { + pageProps?: { __APOLLO_STATE__?: Record } + } + } + | undefined + )?.props?.pageProps?.__APOLLO_STATE__ +} + +/** Extracts event records and cursor metadata from Meetup's public page state. */ +export function parseMeetupPage( + page: Record | undefined, +): PastEventsPage { + const state = getApolloState(page) + if (!state) return { events: [], hasNextPage: false } + + const events = Object.values(state) + .filter( + (value) => + value.__typename === "Event" && + value.id && + value.title && + value.dateTime && + value.eventUrl, + ) + .map((event) => toMeetupEvent(event, state)) + .filter((event): event is MeetupEvent => Boolean(event)) + + const group = Object.values(state).find( + (value) => value.__typename === "Group", + ) as Record | undefined + if (!group) return { events, hasNextPage: false } + + const connection = Object.entries(group).find(([key, value]) => { + if (!key.includes('"status":["ACTIVE","PAST","CANCELLED"]')) { + return false + } + return isApolloRecord(value) && isApolloRecord(value.pageInfo) + }) + const [key, value] = connection || [] + const pageInfo = + isApolloRecord(value) && isApolloRecord(value.pageInfo) + ? value.pageInfo + : undefined + const beforeDateTime = key?.match(/"beforeDateTime":"([^"]+)"/)?.[1] + + return { + events, + totalCount: + isApolloRecord(value) && typeof value.totalCount === "number" + ? value.totalCount + : undefined, + endCursor: + typeof pageInfo?.endCursor === "string" ? pageInfo.endCursor : undefined, + hasNextPage: pageInfo?.hasNextPage === true, + beforeDateTime, + } +} + +const pastEventsQuery = ` + query getPastGroupEvents( + $urlname: String! + $after: String + $beforeDateTime: DateTime + ) { + groupByUrlname(urlname: $urlname) { + events( + filter: { + status: [ACTIVE, PAST, CANCELLED] + beforeDateTime: $beforeDateTime + } + sort: DESC + first: 10 + after: $after + ) { + totalCount + pageInfo { endCursor hasNextPage } + edges { + node { + id title description dateTime endTime eventUrl status + going { totalCount } + venue { name city } + displayPhoto { highResUrl } + featuredEventPhoto { highResUrl } + } + } + } + } + } +` + +async function fetchPastEventsPage( + after: string, + beforeDateTime: string, +): Promise { + const response = await fetch(MEETUP_GRAPHQL_URL, { + method: "POST", + next: { revalidate: 3600 }, + headers: { + "Content-Type": "application/json", + "User-Agent": MEETUP_USER_AGENT, + }, + body: JSON.stringify({ + operationName: "getPastGroupEvents", + variables: { urlname: "coding-bunker", after, beforeDateTime }, + query: pastEventsQuery, + }), + }) + if (!response.ok) return undefined + + const payload = (await response.json()) as { + data?: { + groupByUrlname?: { + events?: { + totalCount?: number + pageInfo?: { endCursor?: string; hasNextPage?: boolean } + edges?: Array<{ node?: MeetupGraphQLEvent }> + } + } + } + } + const events = payload.data?.groupByUrlname?.events + if (!events) return undefined + + return { + events: (events.edges || []) + .map(({ node }) => (node ? toMeetupEvent(node) : undefined)) + .filter((event): event is MeetupEvent => Boolean(event)), + totalCount: events.totalCount, + endCursor: events.pageInfo?.endCursor, + hasNextPage: events.pageInfo?.hasNextPage === true, + } +} + +export function deduplicateAndSortMeetupEvents(events: MeetupEvent[]) { + return [...new Map(events.map((event) => [event.id, event])).values()].sort( + (a, b) => new Date(b.dateTime).getTime() - new Date(a.dateTime).getTime(), + ) +} + /** * Meetup does not provide a public, keyless event API for this group. Its public * event page includes the same event data in Next/Apollo state, which we read at @@ -65,58 +265,37 @@ export async function fetchMeetupEvents(): Promise { [EVENTS_URL, PAST_EVENTS_URL].map(async (url) => { const response = await fetch(url, { next: { revalidate: 3600 }, - headers: { - "User-Agent": "CodingBunkerWebsite/1.0 (+https://codingbunker.it)", - }, + headers: { "User-Agent": MEETUP_USER_AGENT }, }) return response.ok ? getNextData(await response.text()) : undefined }), ) - const events = pages.flatMap((page) => { - const state = ( - page as - | { - props?: { - pageProps?: { __APOLLO_STATE__?: Record } - } - } - | undefined - )?.props?.pageProps?.__APOLLO_STATE__ - if (!state) return [] - return Object.entries(state) - .filter( - ([, value]) => - value.__typename === "Event" && - value.id && - value.title && - value.dateTime && - value.eventUrl, - ) - .map(([key, event]) => { - const venueReference = event.venue?.__ref - const venue = venueReference ? state[venueReference] : undefined - const photoReference = - event.displayPhoto?.__ref || event.featuredEventPhoto?.__ref - const photo = photoReference ? state[photoReference] : undefined - return { - id: event.id || key, - title: event.title || "Evento Coding Bunker", - description: event.description || "", - dateTime: event.dateTime || "", - endTime: event.endTime, - eventUrl: event.eventUrl || EVENTS_URL, - status: event.status || "", - venue: venue?.name || null, - city: venue?.city || null, - attendeeCount: event.going?.totalCount ?? null, - coverImageUrl: - typeof photo?.highResUrl === "string" ? photo.highResUrl : null, - } - }) - }) - return [...new Map(events.map((event) => [event.id, event])).values()].sort( - (a, b) => new Date(a.dateTime).getTime() - new Date(b.dateTime).getTime(), - ) + const [currentPage, initialPastPage] = pages.map(parseMeetupPage) + const pastEvents = [...initialPastPage.events] + const totalCount = initialPastPage.totalCount + let cursor = initialPastPage.endCursor + let hasNextPage = initialPastPage.hasNextPage + const beforeDateTime = initialPastPage.beforeDateTime + + while ( + hasNextPage && + cursor && + beforeDateTime && + (totalCount === undefined || + new Set(pastEvents.map((event) => event.id)).size < totalCount) + ) { + const nextPage = await fetchPastEventsPage(cursor, beforeDateTime) + if (!nextPage) break + pastEvents.push(...nextPage.events) + if (!nextPage.endCursor || nextPage.endCursor === cursor) break + cursor = nextPage.endCursor + hasNextPage = nextPage.hasNextPage + } + + return deduplicateAndSortMeetupEvents([ + ...currentPage.events, + ...pastEvents, + ]) } catch { return [] } @@ -128,7 +307,7 @@ export async function fetchMeetupPastEventCount(): Promise { const response = await fetch(EVENTS_URL, { next: { revalidate: 3600 }, headers: { - "User-Agent": "CodingBunkerWebsite/1.0 (+https://codingbunker.it)", + "User-Agent": MEETUP_USER_AGENT, }, }) if (!response.ok) return undefined @@ -164,7 +343,7 @@ export async function fetchMeetupGroupStats(): Promise { const response = await fetch(EVENTS_URL, { next: { revalidate: 3600 }, headers: { - "User-Agent": "CodingBunkerWebsite/1.0 (+https://codingbunker.it)", + "User-Agent": MEETUP_USER_AGENT, }, }) if (!response.ok) return {} @@ -206,11 +385,16 @@ export async function fetchMeetupGroupStats(): Promise { export function splitMeetupEvents(events: MeetupEvent[], now = new Date()) { const current = now.getTime() return { - upcoming: events.filter( - (event) => - new Date(event.dateTime).getTime() >= current && - event.status !== "CANCELLED", - ), + upcoming: events + .filter( + (event) => + new Date(event.dateTime).getTime() >= current && + event.status !== "CANCELLED", + ) + .sort( + (a, b) => + new Date(a.dateTime).getTime() - new Date(b.dateTime).getTime(), + ), past: events .filter( (event) => diff --git a/tests/fixtures/meetup/initial-past-page.json b/tests/fixtures/meetup/initial-past-page.json new file mode 100644 index 0000000..75e7489 --- /dev/null +++ b/tests/fixtures/meetup/initial-past-page.json @@ -0,0 +1,43 @@ +{ + "props": { + "pageProps": { + "__APOLLO_STATE__": { + "Event:3": { + "__typename": "Event", + "id": "3", + "title": "Newest", + "description": "Latest event", + "dateTime": "2026-10-20T18:30:00+02:00", + "eventUrl": "https://www.meetup.com/coding-bunker/events/3/", + "status": "PAST", + "displayPhoto": { "__ref": "Photo:3" } + }, + "Event:2": { + "__typename": "Event", + "id": "2", + "title": "Duplicate", + "description": "Repeated at the cursor boundary", + "dateTime": "2026-10-10T18:30:00+02:00", + "eventUrl": "https://www.meetup.com/coding-bunker/events/2/", + "status": "PAST" + }, + "Photo:3": { + "__typename": "Photo", + "highResUrl": "https://images.example/newest.jpg" + }, + "Group:1": { + "__typename": "Group", + "events({\"filter\":{\"beforeDateTime\":\"2026-10-26T18:05:38.295Z\",\"status\":[\"ACTIVE\",\"PAST\",\"CANCELLED\"]},\"first\":10,\"sort\":\"DESC\"})": { + "__typename": "GroupEventConnection", + "totalCount": 3, + "pageInfo": { + "__typename": "PageInfo", + "endCursor": "cursor-1", + "hasNextPage": true + } + } + } + } + } + } +} diff --git a/tests/fixtures/meetup/next-past-page.json b/tests/fixtures/meetup/next-past-page.json new file mode 100644 index 0000000..26591c9 --- /dev/null +++ b/tests/fixtures/meetup/next-past-page.json @@ -0,0 +1,35 @@ +{ + "data": { + "groupByUrlname": { + "events": { + "totalCount": 3, + "pageInfo": { "endCursor": "cursor-2", "hasNextPage": false }, + "edges": [ + { + "node": { + "id": "2", + "title": "Duplicate", + "description": "Repeated at the cursor boundary", + "dateTime": "2026-10-10T18:30:00+02:00", + "eventUrl": "https://www.meetup.com/coding-bunker/events/2/", + "status": "PAST" + } + }, + { + "node": { + "id": "1", + "title": "Oldest", + "description": "First event", + "dateTime": "2026-10-01T18:30:00+02:00", + "eventUrl": "https://www.meetup.com/coding-bunker/events/1/", + "status": "PAST", + "featuredEventPhoto": { + "highResUrl": "https://images.example/oldest.jpg" + } + } + } + ] + } + } + } +} diff --git a/tests/meetup.test.ts b/tests/meetup.test.ts new file mode 100644 index 0000000..568063c --- /dev/null +++ b/tests/meetup.test.ts @@ -0,0 +1,87 @@ +import assert from "node:assert/strict" +import { readFileSync } from "node:fs" +import test from "node:test" +import { + deduplicateAndSortMeetupEvents, + fetchMeetupEvents, + parseMeetupPage, +} from "../src/lib/meetup" + +const fixture = (name: string) => + JSON.parse(readFileSync(`tests/fixtures/meetup/${name}`, "utf8")) as Record< + string, + unknown + > + +test("parses cursor metadata and event images from the initial past-events page", () => { + const page = parseMeetupPage(fixture("initial-past-page.json")) + + assert.equal(page.totalCount, 3) + assert.equal(page.endCursor, "cursor-1") + assert.equal(page.hasNextPage, true) + assert.equal(page.beforeDateTime, "2026-10-26T18:05:38.295Z") + assert.equal( + page.events[0]?.coverImageUrl, + "https://images.example/newest.jpg", + ) +}) + +test("fetches every cursor page, deduplicates IDs, and returns newest first", async () => { + const originalFetch = global.fetch + const initial = fixture("initial-past-page.json") + const next = fixture("next-past-page.json") + let request = 0 + global.fetch = async (input) => { + request += 1 + const url = String(input) + if (url.includes("gql2")) { + return new Response(JSON.stringify(next), { status: 200 }) + } + return new Response( + ``, + { status: 200 }, + ) + } + + try { + const events = await fetchMeetupEvents() + assert.equal(request, 3) + assert.deepEqual( + events.map((event) => event.id), + ["3", "2", "1"], + ) + assert.equal(events[2]?.coverImageUrl, "https://images.example/oldest.jpg") + } finally { + global.fetch = originalFetch + } +}) + +test("keeps parsed events when a payload changes or omits the connection", () => { + const page = parseMeetupPage({ + props: { + pageProps: { + __APOLLO_STATE__: { + "Event:1": { + __typename: "Event", + id: "1", + title: "Still usable", + dateTime: "2026-01-01T18:30:00+01:00", + eventUrl: "https://www.meetup.com/coding-bunker/events/1/", + }, + }, + }, + }, + }) + + assert.deepEqual( + page.events.map((event) => event.id), + ["1"], + ) + assert.equal(page.hasNextPage, false) + assert.deepEqual( + deduplicateAndSortMeetupEvents([...page.events, ...page.events]).map( + (event) => event.id, + ), + ["1"], + ) +}) diff --git a/tests/tsconfig.json b/tests/tsconfig.json new file mode 100644 index 0000000..d3ee46e --- /dev/null +++ b/tests/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "noEmit": false, + "outDir": "../.tmp-meetup-tests", + "module": "commonjs", + "moduleResolution": "node", + "jsx": "react-jsx" + }, + "include": ["../next-env.d.ts", "../src/lib/meetup.ts", "./meetup.test.ts"] +}