From 97abd251e35ab8a1901270864880da2bbc2bf018 Mon Sep 17 00:00:00 2001 From: Till JS Date: Sat, 18 Apr 2026 16:51:58 +0200 Subject: [PATCH] =?UTF-8?q?fix(events):=20Eventbrite=20provider=20?= =?UTF-8?q?=E2=80=94=20switch=20from=20dead=20API=20to=20web=20scraping?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eventbrite shut down their public Event Search API (/v3/events/search) in 2023. The provider now uses the website extractor pipeline (mana-research + LLM) to scrape Eventbrite's public search pages. No API key needed — same pipeline as any website source. Also adds mana-events to generate-env.mjs for automatic .env generation. Co-Authored-By: Claude Opus 4.6 (1M context) --- scripts/generate-env.mjs | 16 ++ services/mana-events/src/__tests__/helpers.ts | 1 - .../src/__tests__/providers.test.ts | 19 +- services/mana-events/src/config.ts | 4 +- .../src/discovery/providers/eventbrite.ts | 175 ++++-------------- 5 files changed, 60 insertions(+), 155 deletions(-) diff --git a/scripts/generate-env.mjs b/scripts/generate-env.mjs index 2c1f03b5c..86e84debd 100644 --- a/scripts/generate-env.mjs +++ b/scripts/generate-env.mjs @@ -106,6 +106,22 @@ const APP_CONFIGS = [ }, }, + // Mana Events Service (Hono + Bun, Port 3065) + { + path: 'services/mana-events/.env', + vars: { + PORT: (env) => env.MANA_EVENTS_PORT || '3065', + DATABASE_URL: (env) => + env.MANA_EVENTS_DATABASE_URL || + 'postgresql://mana:devpassword@localhost:5432/mana_platform', + MANA_AUTH_URL: (env) => env.MANA_AUTH_URL || 'http://localhost:3001', + CORS_ORIGINS: (env) => env.CORS_ORIGINS || 'http://localhost:5173', + MANA_RESEARCH_URL: (env) => env.MANA_RESEARCH_URL || 'http://localhost:3068', + MANA_LLM_URL: (env) => env.MANA_LLM_URL || 'http://localhost:3025', + MEETUP_API_KEY: (env) => env.MEETUP_API_KEY || '', + }, + }, + // Chat Server (Hono/Bun) { path: 'apps/chat/apps/server/.env', diff --git a/services/mana-events/src/__tests__/helpers.ts b/services/mana-events/src/__tests__/helpers.ts index 295cdc528..24a65c10a 100644 --- a/services/mana-events/src/__tests__/helpers.ts +++ b/services/mana-events/src/__tests__/helpers.ts @@ -42,7 +42,6 @@ const TEST_CONFIG: Config = { }, manaResearchUrl: 'http://localhost:3068', manaLlmUrl: 'http://localhost:3025', - eventbriteApiKey: null, meetupApiKey: null, }; diff --git a/services/mana-events/src/__tests__/providers.test.ts b/services/mana-events/src/__tests__/providers.test.ts index 227a16146..1d992c539 100644 --- a/services/mana-events/src/__tests__/providers.test.ts +++ b/services/mana-events/src/__tests__/providers.test.ts @@ -29,7 +29,14 @@ describe('Provider registry', () => { }); describe('Eventbrite provider', () => { - it('returns empty with error when API key is not set', async () => { + it('requires a region label', async () => { + const provider = getProvider('eventbrite')!; + const result = await provider.fetchEvents('', 'Test'); + expect(result.events).toHaveLength(0); + expect(result.error).toContain('Region label'); + }); + + it('requires external service config', async () => { const provider = getProvider('eventbrite')!; const result = await provider.fetchEvents('', 'Test', { lat: 47.997, @@ -38,15 +45,7 @@ describe('Eventbrite provider', () => { regionLabel: 'Freiburg', }); expect(result.events).toHaveLength(0); - expect(result.error).toContain('EVENTBRITE_API_KEY'); - }); - - it('gracefully handles missing coordinates (after API key check)', async () => { - // Without API key, the key check fires first — that's the expected behavior - const provider = getProvider('eventbrite')!; - const result = await provider.fetchEvents('', 'Test'); - expect(result.events).toHaveLength(0); - expect(result.error).toBeTruthy(); + expect(result.error).toContain('config'); }); }); diff --git a/services/mana-events/src/config.ts b/services/mana-events/src/config.ts index 584a8161a..efd0f7bc5 100644 --- a/services/mana-events/src/config.ts +++ b/services/mana-events/src/config.ts @@ -18,8 +18,7 @@ export interface Config { // External service URLs for event discovery manaResearchUrl: string; manaLlmUrl: string; - // Platform API keys (optional — providers gracefully skip when unconfigured) - eventbriteApiKey: string | null; + // Platform API key (optional — provider gracefully skips when unconfigured) meetupApiKey: string | null; } @@ -46,7 +45,6 @@ export function loadConfig(): Config { }, manaResearchUrl: process.env.MANA_RESEARCH_URL || 'http://localhost:3068', manaLlmUrl: process.env.MANA_LLM_URL || 'http://localhost:3025', - eventbriteApiKey: process.env.EVENTBRITE_API_KEY || null, meetupApiKey: process.env.MEETUP_API_KEY || null, }; } diff --git a/services/mana-events/src/discovery/providers/eventbrite.ts b/services/mana-events/src/discovery/providers/eventbrite.ts index 9d7cdf82a..6cf060c7c 100644 --- a/services/mana-events/src/discovery/providers/eventbrite.ts +++ b/services/mana-events/src/discovery/providers/eventbrite.ts @@ -1,115 +1,53 @@ /** - * Eventbrite provider — fetches events from the Eventbrite API. + * Eventbrite provider — discovers events via Eventbrite's public search pages. * - * Uses the public Event Search API v3 which requires an API token - * but is free for read operations. + * Eventbrite shut down their public Event Search API (/v3/events/search) + * in 2023. Location-based search is no longer available via API. * - * API docs: https://www.eventbrite.com/platform/api#/reference/event-search - * - * When no API key is configured, this provider is a no-op (returns empty). + * Strategy: use the website extractor (mana-research + LLM) to scrape + * Eventbrite's public search pages (eventbrite.com/d/{region}/events/). + * No API key required — this uses the same pipeline as any website source. */ -import type { EventProvider, ProviderContext, ProviderResult } from './base'; -import type { NormalizedEvent } from '../types'; - -const API_BASE = 'https://www.eventbriteapi.com/v3'; -const FETCH_TIMEOUT_MS = 15_000; - -/** Eventbrite category ID → our category mapping. */ -const CATEGORY_MAP: Record = { - '103': 'music', - '105': 'art', - '101': 'tech', - '108': 'sport', - '110': 'food', - '115': 'family', - '113': 'community', - '109': 'nature', - '104': 'art', // film & media - '107': 'education', // health - '102': 'tech', // science - '106': 'nightlife', // fashion - '111': 'community', // charity - '112': 'community', // government - '114': 'other', // spirituality - '116': 'other', // seasonal - '117': 'education', // home & lifestyle - '199': 'other', -}; - -interface EventbriteEvent { - id: string; - name: { text: string; html?: string }; - description: { text: string; html?: string } | null; - url: string; - start: { utc: string; local: string; timezone: string }; - end: { utc: string; local: string; timezone: string } | null; - venue?: { - name: string; - address: { - localized_address_display: string; - latitude: string; - longitude: string; - }; - } | null; - logo?: { url: string } | null; - category_id: string | null; - is_free: boolean; - ticket_availability?: { - minimum_ticket_price?: { display: string }; - maximum_ticket_price?: { display: string }; - }; -} - -interface EventbriteResponse { - events: EventbriteEvent[]; - pagination: { page_count: number; page_number: number }; -} - -function getApiKey(): string | null { - return process.env.EVENTBRITE_API_KEY || null; -} +import type { EventProvider, ProviderContext, ProviderResult, ExternalServiceConfig } from './base'; +import { extractEventsFromWebsite } from '../website-extractor'; export const eventbriteProvider: EventProvider = { type: 'eventbrite', - async fetchEvents(_url: string, _name: string, ctx?: ProviderContext): Promise { - const apiKey = getApiKey(); - if (!apiKey) { - return { events: [], error: 'EVENTBRITE_API_KEY not configured' }; + async fetchEvents( + _url: string, + _name: string, + ctx?: ProviderContext, + config?: ExternalServiceConfig + ): Promise { + if (!ctx?.regionLabel) { + return { events: [], error: 'Region label required for Eventbrite search' }; + } + if (!config) { + return { events: [], error: 'Missing research/LLM config for Eventbrite extraction' }; } - if (!ctx?.lat || !ctx?.lon) { - return { events: [], error: 'Region coordinates required for Eventbrite search' }; - } + const regionSlug = ctx.regionLabel + .toLowerCase() + .replace(/\s+/g, '-') + .replace(/[^a-z0-9-]/g, ''); + const searchUrl = `https://www.eventbrite.com/d/germany--${regionSlug}/events/`; try { - const params = new URLSearchParams({ - 'location.latitude': String(ctx.lat), - 'location.longitude': String(ctx.lon), - 'location.within': `${ctx.radiusKm ?? 25}km`, - 'start_date.range_start': new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'), - sort_by: 'date', - expand: 'venue,ticket_availability', - }); + const events = await extractEventsFromWebsite( + searchUrl, + `Eventbrite ${ctx.regionLabel}`, + config.manaResearchUrl, + config.manaLlmUrl + ); - // Add keyword search if region label available - if (ctx.regionLabel) { - params.set('q', ctx.regionLabel); + for (const event of events) { + if (!event.externalId) { + event.externalId = `eventbrite:${event.title.slice(0, 30)}`; + } } - const res = await fetch(`${API_BASE}/events/search/?${params}`, { - headers: { Authorization: `Bearer ${apiKey}` }, - signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), - }); - - if (!res.ok) { - const body = await res.text().catch(() => ''); - return { events: [], error: `Eventbrite API ${res.status}: ${body.slice(0, 200)}` }; - } - - const data = (await res.json()) as EventbriteResponse; - const events = data.events.map(toNormalizedEvent).filter(Boolean) as NormalizedEvent[]; return { events }; } catch (err) { return { @@ -119,48 +57,3 @@ export const eventbriteProvider: EventProvider = { } }, }; - -function toNormalizedEvent(eb: EventbriteEvent): NormalizedEvent | null { - const title = eb.name?.text?.trim(); - if (!title) return null; - - const startAt = new Date(eb.start.utc); - if (isNaN(startAt.getTime())) return null; - - const endAt = eb.end ? new Date(eb.end.utc) : null; - - let location: string | null = null; - let lat: number | null = null; - let lon: number | null = null; - - if (eb.venue) { - location = eb.venue.name || eb.venue.address?.localized_address_display || null; - lat = eb.venue.address?.latitude ? parseFloat(eb.venue.address.latitude) : null; - lon = eb.venue.address?.longitude ? parseFloat(eb.venue.address.longitude) : null; - } - - let priceInfo: string | null = null; - if (eb.is_free) { - priceInfo = 'Eintritt frei'; - } else if (eb.ticket_availability?.minimum_ticket_price) { - const min = eb.ticket_availability.minimum_ticket_price.display; - const max = eb.ticket_availability.maximum_ticket_price?.display; - priceInfo = max && max !== min ? `${min} – ${max}` : min; - } - - return { - title, - description: eb.description?.text?.slice(0, 2000) ?? null, - location, - lat, - lon, - startAt, - endAt, - allDay: false, - imageUrl: eb.logo?.url ?? null, - sourceUrl: eb.url, - category: eb.category_id ? (CATEGORY_MAP[eb.category_id] ?? 'other') : null, - priceInfo, - externalId: `eventbrite:${eb.id}`, - }; -}